├── .editorconfig ├── .github └── workflows │ └── docker.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CREDITS.md ├── LICENSE ├── README.md ├── bin └── wpsnapshots ├── composer.json ├── composer.lock ├── docker ├── Dockerfile └── entrypoint.sh ├── phpcs.xml └── src ├── bootstrap.php ├── classes ├── Command │ ├── Configure.php │ ├── Create.php │ ├── CreateRepository.php │ ├── Delete.php │ ├── Download.php │ ├── Pull.php │ ├── Push.php │ └── Search.php ├── Config.php ├── DB.php ├── Log.php ├── Meta.php ├── Repository.php ├── RepositoryManager.php ├── S3.php ├── SearchReplace.php ├── Snapshot.php └── WordPressBridge.php ├── data └── users.csv └── utils.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | trim_trailing_whitespace = true 7 | 8 | [*.{php,js,css,scss}] 9 | end_of_line = lf 10 | insert_final_newline = true 11 | indent_style = tab 12 | indent_size = 4 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Hub 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | release: 8 | types: 9 | - published 10 | 11 | jobs: 12 | publish: 13 | name: Publish Images 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: docker/setup-qemu-action@v1 18 | - uses: docker/setup-buildx-action@v1 19 | # - uses: actions/cache@v2 20 | # with: 21 | # path: /tmp/.buildx-cache 22 | # key: ${{ runner.os }}-buildx-${{ github.sha }} 23 | # restore-keys: | 24 | # ${{ runner.os }}-buildx- 25 | - uses: docker/login-action@v1 26 | with: 27 | username: ${{ secrets.DOCKERHUB_USERNAME }} 28 | password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} 29 | - name: Prepare Environment 30 | id: prep 31 | run: | 32 | DOCKER_IMAGE=10up/wpsnapshots 33 | 34 | if [ "${{ github.event_name }}" == "release" ]; then 35 | RELEASE_NAME="${{ github.event.release.name }}" 36 | 37 | TAGS="${DOCKER_IMAGE}:latest" 38 | TAGS="$TAGS,${DOCKER_IMAGE}:${RELEASE_NAME}" 39 | TAGS="$TAGS,${DOCKER_IMAGE}:${RELEASE_NAME%%.*}" 40 | 41 | echo "::set-output name=archive::${{ github.event.release.tarball_url }}" 42 | echo "::set-output name=tags::${TAGS}" 43 | echo "::set-output name=version::${RELEASE_NAME}" 44 | else 45 | ARCHIVE_URL="${{ github.event.repository.archive_url }}" 46 | ARCHIVE_URL=${ARCHIVE_URL/\{archive_format\}/tarball} 47 | ARCHIVE_URL=${ARCHIVE_URL/\{\/ref\}/\/$GITHUB_REF} 48 | 49 | echo "::set-output name=archive::${ARCHIVE_URL}" 50 | echo "::set-output name=tags::${DOCKER_IMAGE}:${GITHUB_REF##*/}" 51 | echo "::set-output name=version::${GITHUB_REF##*/}" 52 | fi 53 | 54 | echo "::set-output name=created::$(date -u +'%Y-%m-%dT%H:%M:%SZ')" 55 | - uses: docker/build-push-action@v2 56 | with: 57 | cache-from: type=local,src=/tmp/.buildx-cache 58 | cache-to: type=local,dest=/tmp/.buildx-cache 59 | push: true 60 | context: ./docker 61 | file: ./docker/Dockerfile 62 | tags: ${{ steps.prep.outputs.tags }} 63 | build-args: | 64 | WPSNAPSHOTS_ARCHIVE=${{ steps.prep.outputs.archive }} 65 | labels: | 66 | org.opencontainers.image.title=${{ github.event.repository.name }} 67 | org.opencontainers.image.description=${{ github.event.repository.description }} 68 | org.opencontainers.image.url=${{ github.event.repository.html_url }} 69 | org.opencontainers.image.source=${{ github.event.repository.clone_url }} 70 | org.opencontainers.image.version=${{ steps.prep.outputs.version }} 71 | org.opencontainers.image.created=${{ steps.prep.outputs.created }} 72 | org.opencontainers.image.revision=${{ github.sha }} 73 | org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }} 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | wp-local-docker -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file, per [the Keep a Changelog standard](http://keepachangelog.com/). 4 | 5 | ## [2.2.1] - 2022-03-23 6 | ### Fixed 7 | * Fix download command 8 | 9 | ## [2.2.0] - 2021-05-17 10 | * Support overriding version for create and push as well as setting version to `nightly`. Props [dinhtungdu](https://github.com/dinhtungdu). 11 | * Better error handling for `get_download_url`. Props [paulschreiber](https://github.com/paulschreiber). 12 | * Message warning users about using production databases 13 | * Update `rmccue/requests` version 14 | 15 | ## [2.1.0] - 2021-01-22 16 | ### Added 17 | - `--overwirte_local_copy` flag to the `pull` command (props [@eugene-manuilov](https://github.com/eugene-manuilov) via [#71](https://github.com/10up/wpsnapshots/pull/71)). 18 | - `--suppress_instructions` flag to the `pull` command (props [@eugene-manuilov](https://github.com/eugene-manuilov) via [#76](https://github.com/10up/wpsnapshots/pull/76)). 19 | - `--format` option to the search command that accepts json and table values (props [@eugene-manuilov](https://github.com/eugene-manuilov) via [#70](https://github.com/10up/wpsnapshots/pull/70)). 20 | - GitHub actions to build and push a new docker image when a new release is published (props [@eugene-manuilov](https://github.com/eugene-manuilov) via [#72](https://github.com/10up/wpsnapshots/pull/72), [#73](https://github.com/10up/wpsnapshots/pull/73)). 21 | 22 | ### Changed 23 | - Search command arguments to allow multiple queries. 24 | - `--include_files` and `--include_db` flags of `create`, `download`, `pull` and `push` commands to accept negative values. 25 | - `--confirm_wp_version_change` flag of the `pull` command to accept negative values. 26 | - Documentation updates (props [@eugene-manuilov](https://github.com/eugene-manuilov), [@jeffpaul](https://github.com/jeffpaul) via [#75](https://github.com/10up/wpsnapshots/pull/75)). 27 | - Docker image to be compatible with the current version of `10up/wpsnapshots:dev` (props [@eugene-manuilov](https://github.com/eugene-manuilov) via [#74](https://github.com/10up/wpsnapshots/pull/74)). 28 | 29 | ### Removed 30 | - `--column-statistics=0 --no-tablespaces` parameters (props [@felipeelia](https://github.com/felipeelia) via [#68](https://github.com/10up/wpsnapshots/pull/68)). 31 | 32 | ### Fixed 33 | - Empty line issue rendered at the beginning of all commands. 34 | 35 | ## [2.0.1] - 2020-08-06 36 | ### Fixed 37 | - User scrubbing on multisites (props [@felipeelia](https://github.com/felipeelia) via [#66](https://github.com/10up/wpsnapshots/pull/66)). 38 | 39 | ## [2.0] - 2020-07-15 40 | ### Added 41 | - CLI options for slug and description (props [@tlovett1](https://github.com/tlovett1) via [#60](https://github.com/10up/wpsnapshots/pull/60)). 42 | 43 | ### Changed 44 | - Updated questions to clearly identify default values (props [@eugene-manuilov](https://github.com/eugene-manuilov) via [#63](https://github.com/10up/wpsnapshots/pull/63)). 45 | 46 | ## [1.6.3] - 2020-01-18 47 | ### Changed 48 | - Disabled AWS Client Side Monitoring (props [@christianc1](https://github.com/christianc1) via [#59](https://github.com/10up/wpsnapshots/pull/59)). 49 | 50 | ## [1.6.2] - 2019-10-17 51 | ### Fixed 52 | - Sites mapping issue (props [@tlovett1](https://github.com/tlovett1)). 53 | 54 | ## [1.6.1] - 2019-10-17 55 | ### Changed 56 | - Improve hosts file suggestion (props [@adamsilverstein](https://github.com/adamsilverstein) via [#50](https://github.com/10up/wpsnapshots/pull/50)). 57 | - Use `--single-transaction` during the database backup phase of a create or push (props [@dustinrue](https://github.com/dustinrue) via [#58](https://github.com/10up/wpsnapshots/pull/58)). 58 | - Documentation updates (props [@jeffpaul](https://github.com/jeffpaul) via [#54](https://github.com/10up/wpsnapshots/pull/54)). 59 | 60 | ## [1.6] - 2019-04-16 61 | ### Added 62 | - `fs` method, `small` option, and cookie constants (props [@tlovett1](https://github.com/tlovett1)). 63 | 64 | ## [1.5.4] - 2019-03-21 65 | ### Added 66 | - Default, local repo (props [@tlovett1](https://github.com/tlovett1)). 67 | 68 | ### Changed 69 | - If unable to decode config, set empty array (props [@tlovett1](https://github.com/tlovett1)). 70 | 71 | ## [1.5.3] - 2018-12-09 72 | ### Fixed 73 | - S3 push error warning (props [@tlovett1](https://github.com/tlovett1)). 74 | 75 | ## [1.5.2] - 2018-11-14 76 | ### Added 77 | - Scrubs email addresses as well as passwords (props [@ChaosExAnima](https://github.com/ChaosExAnima) via [#47](https://github.com/10up/wpsnapshots/pull/47)). 78 | 79 | ## [1.5.1] - 2018-10-23 80 | ### Added 81 | - Custom snapshot directory environment variable (props [@tlovett1](https://github.com/tlovett1)). 82 | 83 | ## [1.5] - 2018-10-14 84 | ### Changed 85 | - Refactoring from Connection to RepositoryManager and backcompat for filling repo name in (props [@tlovett1](https://github.com/tlovett1)). 86 | 87 | ## [1.4] - 2018-10-10 88 | ### Added 89 | - Multi-repo support and verbose logging (props [@tlovett1](https://github.com/tlovett1)). 90 | 91 | ### Changed 92 | - Search and replace optimizations, get site and home URLs directly (props [@tlovett1](https://github.com/tlovett1)). 93 | 94 | ### Fixed 95 | - `region` variable in `LocationConstraint` (props [@tlovett1](https://github.com/tlovett1)). 96 | 97 | ## [1.3.1] - 208-09-30 98 | ### Fixed 99 | - Line splitting issue (props [@tlovett1](https://github.com/tlovett1)). 100 | 101 | ## [1.3] - 2018-09-23 102 | ### Added 103 | - Enable pushing an already created snapshot, smart defaults for Pull (props [@tlovett1](https://github.com/tlovett1)). 104 | - Auto-write multisite constatns to `wp-config.php` (props [@tlovett1](https://github.com/tlovett1)). 105 | - Port to blog domain, main domain CLI option (props [@tlovett1](https://github.com/tlovett1)). 106 | 107 | ## [1.2.1] - 2018-09-18 108 | ### Fixed 109 | - Provide `signature`, `region`, and `version` in the `::test` method (props [@cmmarslender](https://github.com/cmmarslender) via [#40](https://github.com/10up/wpsnapshots/pull/40)). 110 | 111 | ## [1.2] - 2018-09-16 112 | ### Added 113 | - `Create` and `Download` commands (props [@tlovett1](https://github.com/tlovett1)). 114 | - Snapshot caching (props [@tlovett1](https://github.com/tlovett1)). 115 | - Save `meta.json` file inside snapshot directory with snapshot data (props [@tlovett1](https://github.com/tlovett1)). 116 | - PHPCS standardization and fixes (props [@tlovett1](https://github.com/tlovett1)). 117 | - Store all multisite data in snapshot (props [@tlovett1](https://github.com/tlovett1)). 118 | - Store `blogname` in snapshot (props [@tlovett1](https://github.com/tlovett1)). 119 | - Symfony console update (props [@christianc1](https://github.com/christianc1), [@colorful-tones](https://github.com/colorful-tones) via [#36](https://github.com/10up/wpsnapshots/pull/36)). 120 | - `wpsnapshots` user (props [@tlovett1](https://github.com/tlovett1)). 121 | 122 | ### Changed 123 | - Move config file to `~/.wpsnapshots/config.json`(props [@tlovett1](https://github.com/tlovett1)). 124 | - Abstract out `Snapshot` class to make programmatic interaction with WP Snapshots easier (props [@tlovett1](https://github.com/tlovett1)). 125 | - Properly test MySQL connection before bootstrapping WordPress (props [@tlovett1](https://github.com/tlovett1)). 126 | - Multisite pull changes: should URLs inside existing snapshots, make sure type full URLs instead of paths (props [@tlovett1](https://github.com/tlovett1)). 127 | - `&>` to `2>&1`. `&>` is a bash shortcut for the other, but not running bash with `shell_exec` so sometimes still see the errors (props [@cmmarslender](https://github.com/cmmarslender) via [#37](https://github.com/10up/wpsnapshots/pull/37)). 128 | - Update AWS SDK and require PHP 7 (props [@tlovett1](https://github.com/tlovett1)). 129 | - Ensure WordPRess is present (props [@tlovett1](https://github.com/tlovett1)). 130 | 131 | ## [1.1.3] - 2018-07-31 132 | ### Added 133 | - Reference AWS Setup documentation in main repo readme (props [@christianc1](https://github.com/christianc1) via [#28](https://github.com/10up/wpsnapshots/pull/28)). 134 | 135 | ### Fixed 136 | - Error on `CreateRepository` command (props [@tlovett1](https://github.com/tlovett1), [@EvanAgee](https://github.com/EvanAgee)). 137 | - Bug where downloading WordPress and moving `wp-content` when it already existed threw an error (props [@tlovett1](https://github.com/tlovett1)). 138 | - `maxdepth` parameter order (props [@tlovett1](https://github.com/tlovett1)). 139 | 140 | ## [1.1.2] - 2018-02-23 141 | ### Fixed 142 | - Use specific S3 region when creating a bucket (props [@tlovett1](https://github.com/tlovett1), [@nick-jansen](https://github.com/nick-jansen)). 143 | 144 | ## [1.1.1] - 2018-01-12 145 | ### Added 146 | - `WPSNAPSHOTS` constant to bootstrap (props [@joeyblake](https://github.com/joeyblake) via [#19](https://github.com/10up/wpsnapshots/pull/19)). 147 | - Documentation updates (props [@qriouslad](https://github.com/qriouslad), [@tlovett1](https://github.com/tlovett1) via [#17](https://github.com/10up/wpsnapshots/pull/17)). 148 | 149 | ### Fixed 150 | - Fix space in path bug (props [@tlovett1](https://github.com/tlovett1)). 151 | 152 | ## [1.0] - 2017-12-11 153 | - Initial WP Snapshots release. 154 | 155 | [Unreleased]: https://github.com/10up/wpsnapshots/compare/2.1.0...develop 156 | [2.1.0]: https://github.com/10up/wpsnapshots/compare/2.0.1...2.1.0 157 | [2.0.1]: https://github.com/10up/wpsnapshots/compare/2.0...2.0.1 158 | [2.0]: https://github.com/10up/wpsnapshots/compare/1.6.3...2.0 159 | [1.6.3]: https://github.com/10up/wpsnapshots/compare/1.6.2...1.6.3 160 | [1.6.2]: https://github.com/10up/wpsnapshots/compare/1.6.1...1.6.2 161 | [1.6.1]: https://github.com/10up/wpsnapshots/compare/1.6...1.6.1 162 | [1.6]: https://github.com/10up/wpsnapshots/compare/1.5.4...1.6 163 | [1.5.4]: https://github.com/10up/wpsnapshots/compare/1.5.3...1.5.4 164 | [1.5.3]: https://github.com/10up/wpsnapshots/compare/1.5.2...1.5.3 165 | [1.5.2]: https://github.com/10up/wpsnapshots/compare/1.5.1...1.5.2 166 | [1.5.1]: https://github.com/10up/wpsnapshots/compare/1.5...1.5.1 167 | [1.5]: https://github.com/10up/wpsnapshots/compare/1.4...1.5 168 | [1.4]: https://github.com/10up/wpsnapshots/compare/1.3.1...1.4 169 | [1.3.1]: https://github.com/10up/wpsnapshots/compare/1.3...1.3.1 170 | [1.3]: https://github.com/10up/wpsnapshots/compare/1.2.1...1.3 171 | [1.2.1]: https://github.com/10up/wpsnapshots/compare/1.2...1.2.1 172 | [1.2]: https://github.com/10up/wpsnapshots/compare/1.1.3...1.2 173 | [1.1.3]: https://github.com/10up/wpsnapshots/compare/1.1.2...1.1.3 174 | [1.1.2]: https://github.com/10up/wpsnapshots/compare/1.1.1...1.1.2 175 | [1.1.1]: https://github.com/10up/wpsnapshots/compare/1.0...1.1.1 176 | [1.0]: https://github.com/10up/wpsnapshots/releases/tag/1.0 177 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@10up.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing and Maintaining 2 | 3 | First, thank you for taking the time to contribute! 4 | 5 | The following is a set of guidelines for contributors as well as information and instructions around our maintenance process. The two are closely tied together in terms of how we all work together and set expectations, so while you may not need to know everything in here to submit an issue or pull request, it's best to keep them in the same document. 6 | 7 | ## Ways to contribute 8 | 9 | Contributing isn't just writing code - it's anything that improves the project. All contributions for WP Snapshots are managed right here on GitHub. Here are some ways you can help: 10 | 11 | ### Reporting bugs 12 | 13 | If you're running into an issue with the project, please take a look through [existing issues](https://github.com/10up/wpsnapshots/issues) and [open a new one](https://github.com/10up/wpsnapshots/issues/new) if needed. If you're able, include steps to reproduce, environment information, and screenshots/screencasts as relevant. 14 | 15 | ### Suggesting enhancements 16 | 17 | New features and enhancements are also managed via [issues](https://github.com/10up/wpsnapshots/issues). 18 | 19 | ### Pull requests 20 | 21 | Pull requests represent a proposed solution to a specified problem. They should always reference an issue that describes the problem and contains discussion about the problem itself. Discussion on pull requests should be limited to the pull request itself (e.g., code review). 22 | 23 | For more on how 10up writes and manages code, check out our [10up Engineering Best Practices](https://10up.github.io/Engineering-Best-Practices/). 24 | 25 | ## Workflow 26 | 27 | The `develop` branch is the development branch which means it contains the next version to be released. `master` contains the current latest release and the corresponding stable development version. Always work on the `develop` branch and open up PRs against `develop`. 28 | 29 | ## Release instructions 30 | 31 | 1. Branch: Starting from `develop`, cut a release branch named `release/X.Y.Z` for your changes. 32 | 1. Version bump: Bump the version number in `src/bootstrap.php` if it does not already reflect the version being released. 33 | 1. Changelog: Add/update the changelog in `CHANGELOG.md`. 34 | 1. Props: Update `CREDITS.md` file with any new contributors, confirm maintainers are accurate 35 | 1. Readme updates: Make any other readme changes as necessary in `README.md`. 36 | 1. Merge: Make a non-fast-forward merge from your release branch to `develop` (or merge the pull request), then do the same for `develop` into `master` (`git checkout master && git merge --no-ff develop`). `master` contains the stable development version. 37 | 1. Push: Push your trunk branch to GitHub (e.g. `git push origin master`). 38 | 1. [Wait for build](https://xkcd.com/303/): Head to the [Actions](https://github.com/10up/wpsnapshots/actions) tab in the repo and wait for it to finish if it hasn't already. If it doesn't succeed, figure out why and start over. 39 | 1. Check the build: Check out the `master` branch and test for functionality locally. 40 | 1. Release: Create a [new release](https://github.com/10up/wpsnapshots/releases/new), naming the tag and the release with the new version number, and targeting the `master` branch. Paste the changelog from `CHANGELOG.md` into the body of the release and include a link to the closed issues on the [milestone](https://github.com/10up/wpsnapshots/milestone/#?closed=1). The release should now appear under [releases](https://github.com/10up/wpsnapshots/releases). 41 | 1. Close milestone: Edit the [milestone](https://github.com/10up/wpsnapshots/milestone/#) with release date (in the `Due date (optional)` field) and link to GitHub release (in the `Description field`), then close the milestone. 42 | 1. Punt incomplete items: If any open issues or PRs which were milestoned for `X.Y.Z` do not make it into the release, update their milestone to `X.Y.Z+1`, `X.Y+1.0`, `X+1.0.0`, or `Future Release`. 43 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | The following acknowledges the Maintainers for this repository, those who have Contributed to this repository (via bug reports, code, design, ideas, project management, translation, testing, etc.), and any Libraries utilized. 2 | 3 | ## Maintainers 4 | 5 | The following individuals are responsible for curating the list of issues, responding to pull requests, and ensuring regular releases happen. 6 | 7 | [Taylor Lovett (@tlovett1)](https://github.com/tlovett1) and [Jeffrey Paul (@jeffpaul)](https://github.com/jeffpaul). 8 | 9 | ## Contributors 10 | 11 | Thank you to all the people who have already contributed to this repository via bug reports, code, design, ideas, project management, translation, testing, etc. 12 | 13 | [Taylor Lovett (@tlovett1)](https://github.com/tlovett1), [Tyler Cherpak (@tylercherpak)](https://github.com/tylercherpak), [Peter Sorensen (@psorensen)](https://github.com/psorensen), [Dreb Bits (@drebbits)](https://github.com/drebbits), [Christian Chung (@christianc1)](https://github.com/christianc1), [Prasath Nadarajah (@nprasath002)](https://github.com/nprasath002), [Lukas Pawlik (@lukaspawlik)](https://github.com/lukaspawlik), [Bowo (@qriouslad)](https://github.com/qriouslad), [Joey Blake (@joeyblake)](https://github.com/joeyblake), [Nick Jansen (@nick-jansen)](https://github.com/nick-jansen), [Chris Marslender (@cmmarslender)](https://github.com/cmmarslender), [Echo (@ChaosExAnima)](https://github.com/ChaosExAnima), [Adam Silverstein (@adamsilverstein)](https://github.com/adamsilverstein), [Jeffrey Paul (@jeffpaul)](https://github.com/jeffpaul), [Dustin Rue (@dustinrue)](https://github.com/dustinrue), [Eugene Manuilov (@eugene-manuilov)](https://github.com/eugene-manuilov), [Felipe Elia (@felipeelia)](https://github.com/felipeelia). 14 | 15 | ## Libraries 16 | 17 | The following software libraries are utilized in this repository. 18 | 19 | n/a. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (C) 10up Inc. (https://10up.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # (DEPRECATED) WP Snapshots 2 | 3 | ## THIS PROJECT IS DEPREACTED IN FAVOR OF THE NEW [SNAPSHOTS](https://github.com/10up/snapshots) BUILT AS A WP CLI COMMAND. 4 | 5 | > A project sharing tool for WordPress. 6 | 7 | [![Support Level](https://img.shields.io/badge/support-active-green.svg)](#support-level) [![Release Version](https://img.shields.io/github/tag/10up/wpsnapshots.svg?label=release)](https://github.com/10up/wpsnapshots/releases/latest) [![MIT License](https://img.shields.io/github/license/10up/wpsnapshots.svg)](https://github.com/10up/wpsnapshots/blob/develop/LICENSE.md) 8 | 9 | ## Table of Contents 10 | * [Overview](#overview) 11 | * [2.0 Upgrade Notice](#upgrade) 12 | * [Installation](#install) 13 | * [Windows-specific Installation](#windows) 14 | * [Configure](#configure) 15 | * [Usage](#usage) 16 | * [Identity Access Management and Security](#identity-access-management-and-security) 17 | * [PII](#pii) 18 | * [Troubleshooting](#troubleshooting) 19 | 20 | ## Overview 21 | 22 | WP Snapshots is a project sharing tool for WordPress. Operated via the command line, this tool empowers developers to easily push snapshots of projects into the cloud for sharing with team members. Team members can pull snapshots, either creating new WordPress development environments or into existing installs such that everything "just works". No more downloading files, matching WordPress versions, SQL dumps, fixing table prefixes, running search/replace commands, etc. WP Snapshots even works with multisite. 23 | 24 | WP Snapshots stores snapshots in a centralized repository (AWS). Users setup up WP Snapshots with their team's AWS credentials. Users can then push, pull, and search for snapshots. When a user pushes a snapshot, an instance of their current environment (`wp-content/`, database, etc.) is pushed to Amazon and associated with a particular project slug. When a snapshot is pulled, files are pulled from the cloud either by creating a new WordPress install with the pulled database or by replacing `wp-content/` and intelligently merging the database. WP Snapshots will ensure your local version of WordPress matches the snapshot.. 25 | 26 | A snapshot can contain files, the database, or both. Snapshot files (`wp-content/`) and WordPress database tables are stored in Amazon S3. General snapshot meta data is stored in Amazon DynamoDB. 27 | 28 | ## Upgrade 29 | 30 | WP Snapshots 2.0+ allows users to store database and files independently. As such, some snapshots may only have files or vice-versa. Therefore, WP Snapshots pre 2.0 will break when attempting to pull a 2.0+ snapshot that contains only files or database. WP Snapshots 2.0 works perfectly with older snapshots. If you are running an older version of WP Snapshots, you should upgrade immediately. 31 | 32 | ## Install 33 | 34 | WP Snapshots is easiest to use as a global Composer package. It's highly recommended you run WP Snapshots from WITHIN your dev environment (inside VM or container). Assuming you have Composer/MySQL installed and SSH keys setup within GitHub/10up organiziation, do the following: 35 | 36 | Install WP Snapshots as a global Composer package via Packagist: 37 | ``` 38 | composer global require 10up/wpsnapshots 39 | ``` 40 | 41 | If global Composer scripts are not in your path, add them: 42 | 43 | ``` 44 | export PATH=~/.composer/vendor/bin:$PATH 45 | ``` 46 | 47 | If you are using VVV, add global Composer scripts to your path with this command: 48 | 49 | ``` 50 | export PATH=~/.config/composer/vendor/bin:$PATH 51 | ``` 52 | 53 | ## Configure 54 | 55 | WP Snapshots currently relies on AWS to store files and data. As such, you need to connect to a "repository" hosted on AWS. We have compiled [instructions on how to setup a repository on AWS.](https://github.com/10up/wpsnapshots/wiki/Setting-up-Amazon-Web-Services-to-Store-Snapshots) 56 | 57 | * __wpsnapshots configure \ [--region] [--aws_key] [--aws_secret] [--user_name] [--user_email]__ 58 | 59 | This command sets up WP Snapshots with AWS info and user info. If the optional arguments are not passed 60 | to the command, the user will be promted to enter them, with the exception of region which will default to 61 | `us-west-1`. 62 | 63 | __Example Usage With Prompts :__ 64 | ``` 65 | wpsnapshots configure 10up 66 | ``` 67 | __Example Usage Without Prompts (No Interaction) :__ 68 | ``` 69 | wpsnapshots configure yourcompany --aws_key=AAABBBCCC --aws_secret=AAA111BBB222 --user_name="Jane Smith" --user_email="noreply@yourcompany.com" 70 | ``` 71 | 72 | If WP Snapshots has not been setup for your team/company, you'll need to create the WP Snapshots repository: 73 | 74 | ``` 75 | wpsnapshots create-repository 76 | ``` 77 | 78 | If a repository has already been created, this command will do nothing. 79 | 80 | ## Usage 81 | 82 | WP Snapshots revolves around pushing, pulling, and searching for snapshots. WP Snapshots can push any setup WordPress install. WP Snapshots can pull any snapshot regardless of whether WordPress is setup or not. If WordPress is not setup during a pull, WP Snapshots will guide you through setting it up. 83 | 84 | Documentation for each operation is as follows: 85 | 86 | * __wpsnapshots push [\] [--exclude_uploads] [--exclude] [--scrub] [--path] [--db_host] [--db_name] [--db_user] [--db_password] [--verbose] [--small] [--slug] [--description] [--include_files] [--include_db]__ 87 | 88 | This command pushes a snapshot of a WordPress install to the repository. The command will return a snapshot ID once it's finished that you could pass to a team member. When pushing a snapshot, you can include files and/or the database. 89 | 90 | WP Snapshots scrubs all user information by default including names, emails, and passwords. 91 | 92 | Pushing a snapshot will not replace older snapshots with the same name. There's been discussion on this. It seems easier and safer not to delete old snapshots (otherwise we have to deal with permissions). 93 | 94 | `--small` will take 250 posts from each post type along with the associated terms and post meta and delete the rest of the data. This will modify your local database so be careful. 95 | 96 | * __wpsnapshots pull \ [--path] [--db_host] [--db_name] [--db_user] [--db_password] [--verbose] [--include_files] [--include_db] [--overwrite_local_copy]__ 97 | 98 | This command pulls an existing snapshot from the repository into your current WordPress install replacing your database and/or `wp-content` directory entirely. If a WordPress install does not exist, it will prompt you to create it. The command will interactively prompt you to map URLs to be search and replaced. If the snapshot is a multisite, you will have to map URLs interactively for each blog in the network. This command will also (optionally) match your current version of WordPress with the snapshots. 99 | 100 | After pulling, you can login as admin with the user `wpsnapshots`, password `password`. 101 | 102 | * __wpsnapshots search \... [--format]__ 103 | 104 | This command searches the repository for snapshots. `` will be compared against project names and authors. Multiple queries can be used to search snapshots in different projects. Searching for "\*" will return all snapshots. 105 | 106 | `--format` will render output using selected format. Supported formats are `table` and `json`. Default value is `table`. 107 | 108 | * __wpsnapshots delete \ [--verbose]__ 109 | 110 | This command deletes a snapshot from the repository. 111 | 112 | 113 | ## Identity Access Management and Security 114 | 115 | Snapshots is intended to store development environments. It was not meant to be a secure solution to store sensitive production data in the cloud. 116 | 117 | Snapshots relies on AWS for access management. Each snapshot is associated with a project slug. Using AWS IAM, specific users can be restricted to specific projects. It is your responsibility to ensure your AWS cloud environment is properly secured. 118 | 119 | ## PII 120 | 121 | Snapshots automatically scrubs user information when creating a snapshot. Scrubbed data only includes standard WordPress data e.g. user name, passwords, some user meta, etc. Certain plugins or custom code my store PII elsewhere. It is strongly recommended you review your project for PII (personal identifable information) before pushing snapshots to AWS. 122 | ## Troubleshooting 123 | 124 | * __WP Snapshots can't establish a connection to the database__ 125 | 126 | This can happen if you are calling WP Snapshots outside of your dev environment running in a VM or container. WP Snapshots reads database credentials from `wp-config.php`. In order to connect to your database from your host machine, the database host address will need to be different. For VVV it's 192.168.50.4, for WP Local Docker, it's 127.0.0.1. You can pass a host override via the command line using the `--db_host` option. For VVV, you also might need to pass in a special database user and password like so `--db_user=external --db_password=external`. We recommend running WP Snapshots from inside your development environment. 127 | 128 | * __I received the error: `env: mysqldump: No such file or directory`__ 129 | 130 | You don't have `mysqldump` installed. This is most likely because you are running WP Snapshots from outside your container or VM. Either install `mysqldump` or run WP Snapshots inside your container or VM. 131 | 132 | * __During a pull, MySQL is timing or erroring out while replacing the database.__ 133 | 134 | If you are pulling a massive database, there are all sorts of memory and MySQL optimization issues you can encounter. Try running WP Snapshots as root (`--db_user=root`) so it can attempt to tweak settings for the large import. 135 | 136 | 137 | * __wpsnapshots search displays signature expired error.__ 138 | 139 | This happens when your local system clock is skewed. To fix: 140 | * If you're using VVV, try `vagrant reload` 141 | * If you're using Docker, try `docker-machine ssh default 'sudo ntpclient -s -h pool.ntp.org'` 142 | 143 | * __wpsnapshots push or pull is crashing.__ 144 | 145 | A fatal error is most likely occuring when bootstrapping WordPress. Look at your error log to see what's happening. Often this happens because of a missing PHP class (Memcached) which is a result of not running WP Snapshots inside your environment (container or VM). 146 | 147 | ## Windows 148 | 149 | WP Snapshots has been used successfully inside [Windows Subsystem for Linux](https://msdn.microsoft.com/en-us/commandline/wsl/install-win10). 150 | 151 | ## Support Level 152 | 153 | **Active:** 10up is actively working on this, and we expect to continue work for the foreseeable future including keeping tested up to the most recent version of WordPress. Bug reports, feature requests, questions, and pull requests are welcome. 154 | 155 | ## Like what you see? 156 | 157 | Work with us at 10up 158 | -------------------------------------------------------------------------------- /bin/wpsnapshots: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | =5.6", 16 | "aws/aws-sdk-php": "^3.67" 17 | }, 18 | "scripts": { 19 | "lint": [ 20 | "phpcs ." 21 | ], 22 | "lint-fix": [ 23 | "phpcbf ." 24 | ] 25 | }, 26 | "require-dev": { 27 | "10up/phpcs-composer": "dev-master" 28 | }, 29 | "bin": [ 30 | "bin/wpsnapshots" 31 | ], 32 | "autoload": { 33 | "files": [ 34 | "src/utils.php" 35 | ], 36 | "psr-4": { 37 | "WPSnapshots\\": "./src/classes" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "62af00b99c35b194344dde5a067568c8", 8 | "packages": [ 9 | { 10 | "name": "aws/aws-sdk-php", 11 | "version": "3.67.12", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/aws/aws-sdk-php.git", 15 | "reference": "cdc7180ae87b23d8b34eb8eb8176002081b32c3d" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/cdc7180ae87b23d8b34eb8eb8176002081b32c3d", 20 | "reference": "cdc7180ae87b23d8b34eb8eb8176002081b32c3d", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "ext-json": "*", 25 | "ext-pcre": "*", 26 | "ext-simplexml": "*", 27 | "ext-spl": "*", 28 | "guzzlehttp/guzzle": "^5.3.1|^6.2.1", 29 | "guzzlehttp/promises": "~1.0", 30 | "guzzlehttp/psr7": "^1.4.1", 31 | "mtdowling/jmespath.php": "~2.2", 32 | "php": ">=5.5" 33 | }, 34 | "require-dev": { 35 | "andrewsville/php-token-reflection": "^1.4", 36 | "aws/aws-php-sns-message-validator": "~1.0", 37 | "behat/behat": "~3.0", 38 | "doctrine/cache": "~1.4", 39 | "ext-dom": "*", 40 | "ext-openssl": "*", 41 | "nette/neon": "^2.3", 42 | "phpunit/phpunit": "^4.8.35|^5.4.3", 43 | "psr/cache": "^1.0" 44 | }, 45 | "suggest": { 46 | "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", 47 | "doctrine/cache": "To use the DoctrineCacheAdapter", 48 | "ext-curl": "To send requests using cURL", 49 | "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages" 50 | }, 51 | "type": "library", 52 | "extra": { 53 | "branch-alias": { 54 | "dev-master": "3.0-dev" 55 | } 56 | }, 57 | "autoload": { 58 | "psr-4": { 59 | "Aws\\": "src/" 60 | }, 61 | "files": [ 62 | "src/functions.php" 63 | ] 64 | }, 65 | "notification-url": "https://packagist.org/downloads/", 66 | "license": [ 67 | "Apache-2.0" 68 | ], 69 | "authors": [ 70 | { 71 | "name": "Amazon Web Services", 72 | "homepage": "http://aws.amazon.com" 73 | } 74 | ], 75 | "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", 76 | "homepage": "http://aws.amazon.com/sdkforphp", 77 | "keywords": [ 78 | "amazon", 79 | "aws", 80 | "cloud", 81 | "dynamodb", 82 | "ec2", 83 | "glacier", 84 | "s3", 85 | "sdk" 86 | ], 87 | "time": "2018-09-13T18:34:42+00:00" 88 | }, 89 | { 90 | "name": "guzzlehttp/guzzle", 91 | "version": "6.3.3", 92 | "source": { 93 | "type": "git", 94 | "url": "https://github.com/guzzle/guzzle.git", 95 | "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba" 96 | }, 97 | "dist": { 98 | "type": "zip", 99 | "url": "https://api.github.com/repos/guzzle/guzzle/zipball/407b0cb880ace85c9b63c5f9551db498cb2d50ba", 100 | "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba", 101 | "shasum": "" 102 | }, 103 | "require": { 104 | "guzzlehttp/promises": "^1.0", 105 | "guzzlehttp/psr7": "^1.4", 106 | "php": ">=5.5" 107 | }, 108 | "require-dev": { 109 | "ext-curl": "*", 110 | "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0", 111 | "psr/log": "^1.0" 112 | }, 113 | "suggest": { 114 | "psr/log": "Required for using the Log middleware" 115 | }, 116 | "type": "library", 117 | "extra": { 118 | "branch-alias": { 119 | "dev-master": "6.3-dev" 120 | } 121 | }, 122 | "autoload": { 123 | "files": [ 124 | "src/functions_include.php" 125 | ], 126 | "psr-4": { 127 | "GuzzleHttp\\": "src/" 128 | } 129 | }, 130 | "notification-url": "https://packagist.org/downloads/", 131 | "license": [ 132 | "MIT" 133 | ], 134 | "authors": [ 135 | { 136 | "name": "Michael Dowling", 137 | "email": "mtdowling@gmail.com", 138 | "homepage": "https://github.com/mtdowling" 139 | } 140 | ], 141 | "description": "Guzzle is a PHP HTTP client library", 142 | "homepage": "http://guzzlephp.org/", 143 | "keywords": [ 144 | "client", 145 | "curl", 146 | "framework", 147 | "http", 148 | "http client", 149 | "rest", 150 | "web service" 151 | ], 152 | "time": "2018-04-22T15:46:56+00:00" 153 | }, 154 | { 155 | "name": "guzzlehttp/promises", 156 | "version": "v1.3.1", 157 | "source": { 158 | "type": "git", 159 | "url": "https://github.com/guzzle/promises.git", 160 | "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646" 161 | }, 162 | "dist": { 163 | "type": "zip", 164 | "url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646", 165 | "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646", 166 | "shasum": "" 167 | }, 168 | "require": { 169 | "php": ">=5.5.0" 170 | }, 171 | "require-dev": { 172 | "phpunit/phpunit": "^4.0" 173 | }, 174 | "type": "library", 175 | "extra": { 176 | "branch-alias": { 177 | "dev-master": "1.4-dev" 178 | } 179 | }, 180 | "autoload": { 181 | "psr-4": { 182 | "GuzzleHttp\\Promise\\": "src/" 183 | }, 184 | "files": [ 185 | "src/functions_include.php" 186 | ] 187 | }, 188 | "notification-url": "https://packagist.org/downloads/", 189 | "license": [ 190 | "MIT" 191 | ], 192 | "authors": [ 193 | { 194 | "name": "Michael Dowling", 195 | "email": "mtdowling@gmail.com", 196 | "homepage": "https://github.com/mtdowling" 197 | } 198 | ], 199 | "description": "Guzzle promises library", 200 | "keywords": [ 201 | "promise" 202 | ], 203 | "time": "2016-12-20T10:07:11+00:00" 204 | }, 205 | { 206 | "name": "guzzlehttp/psr7", 207 | "version": "1.4.2", 208 | "source": { 209 | "type": "git", 210 | "url": "https://github.com/guzzle/psr7.git", 211 | "reference": "f5b8a8512e2b58b0071a7280e39f14f72e05d87c" 212 | }, 213 | "dist": { 214 | "type": "zip", 215 | "url": "https://api.github.com/repos/guzzle/psr7/zipball/f5b8a8512e2b58b0071a7280e39f14f72e05d87c", 216 | "reference": "f5b8a8512e2b58b0071a7280e39f14f72e05d87c", 217 | "shasum": "" 218 | }, 219 | "require": { 220 | "php": ">=5.4.0", 221 | "psr/http-message": "~1.0" 222 | }, 223 | "provide": { 224 | "psr/http-message-implementation": "1.0" 225 | }, 226 | "require-dev": { 227 | "phpunit/phpunit": "~4.0" 228 | }, 229 | "type": "library", 230 | "extra": { 231 | "branch-alias": { 232 | "dev-master": "1.4-dev" 233 | } 234 | }, 235 | "autoload": { 236 | "psr-4": { 237 | "GuzzleHttp\\Psr7\\": "src/" 238 | }, 239 | "files": [ 240 | "src/functions_include.php" 241 | ] 242 | }, 243 | "notification-url": "https://packagist.org/downloads/", 244 | "license": [ 245 | "MIT" 246 | ], 247 | "authors": [ 248 | { 249 | "name": "Michael Dowling", 250 | "email": "mtdowling@gmail.com", 251 | "homepage": "https://github.com/mtdowling" 252 | }, 253 | { 254 | "name": "Tobias Schultze", 255 | "homepage": "https://github.com/Tobion" 256 | } 257 | ], 258 | "description": "PSR-7 message implementation that also provides common utility methods", 259 | "keywords": [ 260 | "http", 261 | "message", 262 | "request", 263 | "response", 264 | "stream", 265 | "uri", 266 | "url" 267 | ], 268 | "time": "2017-03-20T17:10:46+00:00" 269 | }, 270 | { 271 | "name": "mtdowling/jmespath.php", 272 | "version": "2.4.0", 273 | "source": { 274 | "type": "git", 275 | "url": "https://github.com/jmespath/jmespath.php.git", 276 | "reference": "adcc9531682cf87dfda21e1fd5d0e7a41d292fac" 277 | }, 278 | "dist": { 279 | "type": "zip", 280 | "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/adcc9531682cf87dfda21e1fd5d0e7a41d292fac", 281 | "reference": "adcc9531682cf87dfda21e1fd5d0e7a41d292fac", 282 | "shasum": "" 283 | }, 284 | "require": { 285 | "php": ">=5.4.0" 286 | }, 287 | "require-dev": { 288 | "phpunit/phpunit": "~4.0" 289 | }, 290 | "bin": [ 291 | "bin/jp.php" 292 | ], 293 | "type": "library", 294 | "extra": { 295 | "branch-alias": { 296 | "dev-master": "2.0-dev" 297 | } 298 | }, 299 | "autoload": { 300 | "psr-4": { 301 | "JmesPath\\": "src/" 302 | }, 303 | "files": [ 304 | "src/JmesPath.php" 305 | ] 306 | }, 307 | "notification-url": "https://packagist.org/downloads/", 308 | "license": [ 309 | "MIT" 310 | ], 311 | "authors": [ 312 | { 313 | "name": "Michael Dowling", 314 | "email": "mtdowling@gmail.com", 315 | "homepage": "https://github.com/mtdowling" 316 | } 317 | ], 318 | "description": "Declaratively specify how to extract elements from a JSON document", 319 | "keywords": [ 320 | "json", 321 | "jsonpath" 322 | ], 323 | "time": "2016-12-03T22:08:25+00:00" 324 | }, 325 | { 326 | "name": "psr/http-message", 327 | "version": "1.0.1", 328 | "source": { 329 | "type": "git", 330 | "url": "https://github.com/php-fig/http-message.git", 331 | "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" 332 | }, 333 | "dist": { 334 | "type": "zip", 335 | "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", 336 | "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", 337 | "shasum": "" 338 | }, 339 | "require": { 340 | "php": ">=5.3.0" 341 | }, 342 | "type": "library", 343 | "extra": { 344 | "branch-alias": { 345 | "dev-master": "1.0.x-dev" 346 | } 347 | }, 348 | "autoload": { 349 | "psr-4": { 350 | "Psr\\Http\\Message\\": "src/" 351 | } 352 | }, 353 | "notification-url": "https://packagist.org/downloads/", 354 | "license": [ 355 | "MIT" 356 | ], 357 | "authors": [ 358 | { 359 | "name": "PHP-FIG", 360 | "homepage": "http://www.php-fig.org/" 361 | } 362 | ], 363 | "description": "Common interface for HTTP messages", 364 | "homepage": "https://github.com/php-fig/http-message", 365 | "keywords": [ 366 | "http", 367 | "http-message", 368 | "psr", 369 | "psr-7", 370 | "request", 371 | "response" 372 | ], 373 | "time": "2016-08-06T14:39:51+00:00" 374 | }, 375 | { 376 | "name": "rmccue/requests", 377 | "version": "v1.8.0", 378 | "source": { 379 | "type": "git", 380 | "url": "https://github.com/WordPress/Requests.git", 381 | "reference": "afbe4790e4def03581c4a0963a1e8aa01f6030f1" 382 | }, 383 | "dist": { 384 | "type": "zip", 385 | "url": "https://api.github.com/repos/WordPress/Requests/zipball/afbe4790e4def03581c4a0963a1e8aa01f6030f1", 386 | "reference": "afbe4790e4def03581c4a0963a1e8aa01f6030f1", 387 | "shasum": "" 388 | }, 389 | "require": { 390 | "php": ">=5.2" 391 | }, 392 | "require-dev": { 393 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7", 394 | "php-parallel-lint/php-console-highlighter": "^0.5.0", 395 | "php-parallel-lint/php-parallel-lint": "^1.3", 396 | "phpcompatibility/php-compatibility": "^9.0", 397 | "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5", 398 | "requests/test-server": "dev-master", 399 | "squizlabs/php_codesniffer": "^3.5", 400 | "wp-coding-standards/wpcs": "^2.0" 401 | }, 402 | "type": "library", 403 | "autoload": { 404 | "psr-0": { 405 | "Requests": "library/" 406 | } 407 | }, 408 | "notification-url": "https://packagist.org/downloads/", 409 | "license": [ 410 | "ISC" 411 | ], 412 | "authors": [ 413 | { 414 | "name": "Ryan McCue", 415 | "homepage": "http://ryanmccue.info" 416 | } 417 | ], 418 | "description": "A HTTP library written in PHP, for human beings.", 419 | "homepage": "http://github.com/WordPress/Requests", 420 | "keywords": [ 421 | "curl", 422 | "fsockopen", 423 | "http", 424 | "idna", 425 | "ipv6", 426 | "iri", 427 | "sockets" 428 | ], 429 | "time": "2021-04-27T11:05:25+00:00" 430 | }, 431 | { 432 | "name": "symfony/console", 433 | "version": "v4.1.4", 434 | "source": { 435 | "type": "git", 436 | "url": "https://github.com/symfony/console.git", 437 | "reference": "ca80b8ced97cf07390078b29773dc384c39eee1f" 438 | }, 439 | "dist": { 440 | "type": "zip", 441 | "url": "https://api.github.com/repos/symfony/console/zipball/ca80b8ced97cf07390078b29773dc384c39eee1f", 442 | "reference": "ca80b8ced97cf07390078b29773dc384c39eee1f", 443 | "shasum": "" 444 | }, 445 | "require": { 446 | "php": "^7.1.3", 447 | "symfony/polyfill-mbstring": "~1.0" 448 | }, 449 | "conflict": { 450 | "symfony/dependency-injection": "<3.4", 451 | "symfony/process": "<3.3" 452 | }, 453 | "require-dev": { 454 | "psr/log": "~1.0", 455 | "symfony/config": "~3.4|~4.0", 456 | "symfony/dependency-injection": "~3.4|~4.0", 457 | "symfony/event-dispatcher": "~3.4|~4.0", 458 | "symfony/lock": "~3.4|~4.0", 459 | "symfony/process": "~3.4|~4.0" 460 | }, 461 | "suggest": { 462 | "psr/log-implementation": "For using the console logger", 463 | "symfony/event-dispatcher": "", 464 | "symfony/lock": "", 465 | "symfony/process": "" 466 | }, 467 | "type": "library", 468 | "extra": { 469 | "branch-alias": { 470 | "dev-master": "4.1-dev" 471 | } 472 | }, 473 | "autoload": { 474 | "psr-4": { 475 | "Symfony\\Component\\Console\\": "" 476 | }, 477 | "exclude-from-classmap": [ 478 | "/Tests/" 479 | ] 480 | }, 481 | "notification-url": "https://packagist.org/downloads/", 482 | "license": [ 483 | "MIT" 484 | ], 485 | "authors": [ 486 | { 487 | "name": "Fabien Potencier", 488 | "email": "fabien@symfony.com" 489 | }, 490 | { 491 | "name": "Symfony Community", 492 | "homepage": "https://symfony.com/contributors" 493 | } 494 | ], 495 | "description": "Symfony Console Component", 496 | "homepage": "https://symfony.com", 497 | "time": "2018-07-26T11:24:31+00:00" 498 | }, 499 | { 500 | "name": "symfony/polyfill-mbstring", 501 | "version": "v1.9.0", 502 | "source": { 503 | "type": "git", 504 | "url": "https://github.com/symfony/polyfill-mbstring.git", 505 | "reference": "d0cd638f4634c16d8df4508e847f14e9e43168b8" 506 | }, 507 | "dist": { 508 | "type": "zip", 509 | "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d0cd638f4634c16d8df4508e847f14e9e43168b8", 510 | "reference": "d0cd638f4634c16d8df4508e847f14e9e43168b8", 511 | "shasum": "" 512 | }, 513 | "require": { 514 | "php": ">=5.3.3" 515 | }, 516 | "suggest": { 517 | "ext-mbstring": "For best performance" 518 | }, 519 | "type": "library", 520 | "extra": { 521 | "branch-alias": { 522 | "dev-master": "1.9-dev" 523 | } 524 | }, 525 | "autoload": { 526 | "psr-4": { 527 | "Symfony\\Polyfill\\Mbstring\\": "" 528 | }, 529 | "files": [ 530 | "bootstrap.php" 531 | ] 532 | }, 533 | "notification-url": "https://packagist.org/downloads/", 534 | "license": [ 535 | "MIT" 536 | ], 537 | "authors": [ 538 | { 539 | "name": "Nicolas Grekas", 540 | "email": "p@tchwork.com" 541 | }, 542 | { 543 | "name": "Symfony Community", 544 | "homepage": "https://symfony.com/contributors" 545 | } 546 | ], 547 | "description": "Symfony polyfill for the Mbstring extension", 548 | "homepage": "https://symfony.com", 549 | "keywords": [ 550 | "compatibility", 551 | "mbstring", 552 | "polyfill", 553 | "portable", 554 | "shim" 555 | ], 556 | "time": "2018-08-06T14:22:27+00:00" 557 | } 558 | ], 559 | "packages-dev": [ 560 | { 561 | "name": "10up/phpcs-composer", 562 | "version": "dev-master", 563 | "source": { 564 | "type": "git", 565 | "url": "https://github.com/10up/phpcs-composer.git", 566 | "reference": "b3b1f2149dd01a42e8c384f1f8e1d01f161eb04f" 567 | }, 568 | "dist": { 569 | "type": "zip", 570 | "url": "https://api.github.com/repos/10up/phpcs-composer/zipball/b3b1f2149dd01a42e8c384f1f8e1d01f161eb04f", 571 | "reference": "b3b1f2149dd01a42e8c384f1f8e1d01f161eb04f", 572 | "shasum": "" 573 | }, 574 | "require": { 575 | "composer-plugin-api": "^1.1", 576 | "wimg/php-compatibility": "*", 577 | "wp-coding-standards/wpcs": "*" 578 | }, 579 | "type": "composer-plugin", 580 | "extra": { 581 | "class": "TenUp\\PHPCS_Composer\\PHPCSConfig" 582 | }, 583 | "autoload": { 584 | "psr-4": { 585 | "TenUp\\PHPCS_Composer\\": "src/" 586 | } 587 | }, 588 | "notification-url": "https://packagist.org/downloads/", 589 | "license": [ 590 | "MIT" 591 | ], 592 | "authors": [ 593 | { 594 | "name": "Ephraim Gregor", 595 | "email": "ephraim.gregor@10up.com" 596 | } 597 | ], 598 | "time": "2018-08-30T03:14:09+00:00" 599 | }, 600 | { 601 | "name": "guzzle/guzzle", 602 | "version": "v3.9.3", 603 | "source": { 604 | "type": "git", 605 | "url": "https://github.com/guzzle/guzzle3.git", 606 | "reference": "0645b70d953bc1c067bbc8d5bc53194706b628d9" 607 | }, 608 | "dist": { 609 | "type": "zip", 610 | "url": "https://api.github.com/repos/guzzle/guzzle3/zipball/0645b70d953bc1c067bbc8d5bc53194706b628d9", 611 | "reference": "0645b70d953bc1c067bbc8d5bc53194706b628d9", 612 | "shasum": "" 613 | }, 614 | "require": { 615 | "ext-curl": "*", 616 | "php": ">=5.3.3", 617 | "symfony/event-dispatcher": "~2.1" 618 | }, 619 | "replace": { 620 | "guzzle/batch": "self.version", 621 | "guzzle/cache": "self.version", 622 | "guzzle/common": "self.version", 623 | "guzzle/http": "self.version", 624 | "guzzle/inflection": "self.version", 625 | "guzzle/iterator": "self.version", 626 | "guzzle/log": "self.version", 627 | "guzzle/parser": "self.version", 628 | "guzzle/plugin": "self.version", 629 | "guzzle/plugin-async": "self.version", 630 | "guzzle/plugin-backoff": "self.version", 631 | "guzzle/plugin-cache": "self.version", 632 | "guzzle/plugin-cookie": "self.version", 633 | "guzzle/plugin-curlauth": "self.version", 634 | "guzzle/plugin-error-response": "self.version", 635 | "guzzle/plugin-history": "self.version", 636 | "guzzle/plugin-log": "self.version", 637 | "guzzle/plugin-md5": "self.version", 638 | "guzzle/plugin-mock": "self.version", 639 | "guzzle/plugin-oauth": "self.version", 640 | "guzzle/service": "self.version", 641 | "guzzle/stream": "self.version" 642 | }, 643 | "require-dev": { 644 | "doctrine/cache": "~1.3", 645 | "monolog/monolog": "~1.0", 646 | "phpunit/phpunit": "3.7.*", 647 | "psr/log": "~1.0", 648 | "symfony/class-loader": "~2.1", 649 | "zendframework/zend-cache": "2.*,<2.3", 650 | "zendframework/zend-log": "2.*,<2.3" 651 | }, 652 | "suggest": { 653 | "guzzlehttp/guzzle": "Guzzle 5 has moved to a new package name. The package you have installed, Guzzle 3, is deprecated." 654 | }, 655 | "type": "library", 656 | "extra": { 657 | "branch-alias": { 658 | "dev-master": "3.9-dev" 659 | } 660 | }, 661 | "autoload": { 662 | "psr-0": { 663 | "Guzzle": "src/", 664 | "Guzzle\\Tests": "tests/" 665 | } 666 | }, 667 | "notification-url": "https://packagist.org/downloads/", 668 | "license": [ 669 | "MIT" 670 | ], 671 | "authors": [ 672 | { 673 | "name": "Michael Dowling", 674 | "email": "mtdowling@gmail.com", 675 | "homepage": "https://github.com/mtdowling" 676 | }, 677 | { 678 | "name": "Guzzle Community", 679 | "homepage": "https://github.com/guzzle/guzzle/contributors" 680 | } 681 | ], 682 | "description": "PHP HTTP client. This library is deprecated in favor of https://packagist.org/packages/guzzlehttp/guzzle", 683 | "homepage": "http://guzzlephp.org/", 684 | "keywords": [ 685 | "client", 686 | "curl", 687 | "framework", 688 | "http", 689 | "http client", 690 | "rest", 691 | "web service" 692 | ], 693 | "abandoned": "guzzlehttp/guzzle", 694 | "time": "2015-03-18T18:23:50+00:00" 695 | }, 696 | { 697 | "name": "squizlabs/php_codesniffer", 698 | "version": "3.3.1", 699 | "source": { 700 | "type": "git", 701 | "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", 702 | "reference": "628a481780561150481a9ec74709092b9759b3ec" 703 | }, 704 | "dist": { 705 | "type": "zip", 706 | "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/628a481780561150481a9ec74709092b9759b3ec", 707 | "reference": "628a481780561150481a9ec74709092b9759b3ec", 708 | "shasum": "" 709 | }, 710 | "require": { 711 | "ext-simplexml": "*", 712 | "ext-tokenizer": "*", 713 | "ext-xmlwriter": "*", 714 | "php": ">=5.4.0" 715 | }, 716 | "require-dev": { 717 | "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" 718 | }, 719 | "bin": [ 720 | "bin/phpcs", 721 | "bin/phpcbf" 722 | ], 723 | "type": "library", 724 | "extra": { 725 | "branch-alias": { 726 | "dev-master": "3.x-dev" 727 | } 728 | }, 729 | "notification-url": "https://packagist.org/downloads/", 730 | "license": [ 731 | "BSD-3-Clause" 732 | ], 733 | "authors": [ 734 | { 735 | "name": "Greg Sherwood", 736 | "role": "lead" 737 | } 738 | ], 739 | "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", 740 | "homepage": "http://www.squizlabs.com/php-codesniffer", 741 | "keywords": [ 742 | "phpcs", 743 | "standards" 744 | ], 745 | "time": "2018-07-26T23:47:18+00:00" 746 | }, 747 | { 748 | "name": "symfony/event-dispatcher", 749 | "version": "v2.8.45", 750 | "source": { 751 | "type": "git", 752 | "url": "https://github.com/symfony/event-dispatcher.git", 753 | "reference": "84ae343f39947aa084426ed1138bb96bf94d1f12" 754 | }, 755 | "dist": { 756 | "type": "zip", 757 | "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/84ae343f39947aa084426ed1138bb96bf94d1f12", 758 | "reference": "84ae343f39947aa084426ed1138bb96bf94d1f12", 759 | "shasum": "" 760 | }, 761 | "require": { 762 | "php": ">=5.3.9" 763 | }, 764 | "require-dev": { 765 | "psr/log": "~1.0", 766 | "symfony/config": "^2.0.5|~3.0.0", 767 | "symfony/dependency-injection": "~2.6|~3.0.0", 768 | "symfony/expression-language": "~2.6|~3.0.0", 769 | "symfony/stopwatch": "~2.3|~3.0.0" 770 | }, 771 | "suggest": { 772 | "symfony/dependency-injection": "", 773 | "symfony/http-kernel": "" 774 | }, 775 | "type": "library", 776 | "extra": { 777 | "branch-alias": { 778 | "dev-master": "2.8-dev" 779 | } 780 | }, 781 | "autoload": { 782 | "psr-4": { 783 | "Symfony\\Component\\EventDispatcher\\": "" 784 | }, 785 | "exclude-from-classmap": [ 786 | "/Tests/" 787 | ] 788 | }, 789 | "notification-url": "https://packagist.org/downloads/", 790 | "license": [ 791 | "MIT" 792 | ], 793 | "authors": [ 794 | { 795 | "name": "Fabien Potencier", 796 | "email": "fabien@symfony.com" 797 | }, 798 | { 799 | "name": "Symfony Community", 800 | "homepage": "https://symfony.com/contributors" 801 | } 802 | ], 803 | "description": "Symfony EventDispatcher Component", 804 | "homepage": "https://symfony.com", 805 | "time": "2018-07-26T09:03:18+00:00" 806 | }, 807 | { 808 | "name": "wimg/php-compatibility", 809 | "version": "8.2.0", 810 | "source": { 811 | "type": "git", 812 | "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", 813 | "reference": "eaf613c1a8265bcfd7b0ab690783f2aef519f78a" 814 | }, 815 | "dist": { 816 | "type": "zip", 817 | "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/eaf613c1a8265bcfd7b0ab690783f2aef519f78a", 818 | "reference": "eaf613c1a8265bcfd7b0ab690783f2aef519f78a", 819 | "shasum": "" 820 | }, 821 | "require": { 822 | "php": ">=5.3", 823 | "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" 824 | }, 825 | "conflict": { 826 | "squizlabs/php_codesniffer": "2.6.2" 827 | }, 828 | "require-dev": { 829 | "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" 830 | }, 831 | "suggest": { 832 | "dealerdirect/phpcodesniffer-composer-installer": "^0.4.3 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", 833 | "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." 834 | }, 835 | "type": "phpcodesniffer-standard", 836 | "autoload": { 837 | "psr-4": { 838 | "PHPCompatibility\\": "PHPCompatibility/" 839 | } 840 | }, 841 | "notification-url": "https://packagist.org/downloads/", 842 | "license": [ 843 | "LGPL-3.0-or-later" 844 | ], 845 | "authors": [ 846 | { 847 | "name": "Wim Godden", 848 | "role": "lead" 849 | } 850 | ], 851 | "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP version compatibility.", 852 | "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", 853 | "keywords": [ 854 | "compatibility", 855 | "phpcs", 856 | "standards" 857 | ], 858 | "abandoned": "phpcompatibility/php-compatibility", 859 | "time": "2018-07-17T13:42:26+00:00" 860 | }, 861 | { 862 | "name": "wp-coding-standards/wpcs", 863 | "version": "1.0.0", 864 | "source": { 865 | "type": "git", 866 | "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", 867 | "reference": "539c6d74e6207daa22b7ea754d6f103e9abb2755" 868 | }, 869 | "dist": { 870 | "type": "zip", 871 | "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/539c6d74e6207daa22b7ea754d6f103e9abb2755", 872 | "reference": "539c6d74e6207daa22b7ea754d6f103e9abb2755", 873 | "shasum": "" 874 | }, 875 | "require": { 876 | "php": ">=5.3", 877 | "squizlabs/php_codesniffer": "^2.9.0 || ^3.0.2" 878 | }, 879 | "require-dev": { 880 | "phpcompatibility/php-compatibility": "*" 881 | }, 882 | "suggest": { 883 | "dealerdirect/phpcodesniffer-composer-installer": "^0.4.3 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically." 884 | }, 885 | "type": "phpcodesniffer-standard", 886 | "notification-url": "https://packagist.org/downloads/", 887 | "license": [ 888 | "MIT" 889 | ], 890 | "authors": [ 891 | { 892 | "name": "Contributors", 893 | "homepage": "https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards/graphs/contributors" 894 | } 895 | ], 896 | "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", 897 | "keywords": [ 898 | "phpcs", 899 | "standards", 900 | "wordpress" 901 | ], 902 | "time": "2018-07-25T18:10:35+00:00" 903 | } 904 | ], 905 | "aliases": [], 906 | "minimum-stability": "stable", 907 | "stability-flags": { 908 | "10up/phpcs-composer": 20 909 | }, 910 | "prefer-stable": false, 911 | "prefer-lowest": false, 912 | "platform": { 913 | "php": ">=5.6" 914 | }, 915 | "platform-dev": [], 916 | "plugin-api-version": "1.1.0" 917 | } 918 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM 10up/phpfpm 2 | 3 | ARG WPSNAPSHOTS_ARCHIVE 4 | ENV WPSNAPSHOTS_ARCHIVE $WPSNAPSHOTS_ARCHIVE 5 | 6 | WORKDIR /opt/wpsnapshots 7 | 8 | RUN useradd wpsnapshots && \ 9 | mkdir -p /home/wpsnapshots && \ 10 | chown -R wpsnapshots:wpsnapshots /home/wpsnapshots && \ 11 | wget -q -c ${WPSNAPSHOTS_ARCHIVE} -O - | tar -xz --strip 1 && \ 12 | composer install --no-dev --no-progress && \ 13 | composer clear-cache && \ 14 | chown -R wpsnapshots:wpsnapshots /opt/wpsnapshots 15 | 16 | COPY entrypoint.sh /entrypoint.sh 17 | 18 | ENTRYPOINT [ "/entrypoint.sh" ] 19 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -d /home/wpsnapshots/.wpsnapshots ]; then 4 | www_uid=`stat -c "%u" /home/wpsnapshots/.wpsnapshots` 5 | www_gid=`stat -c "%g" /home/wpsnapshots/.wpsnapshots` 6 | if [ ! $www_uid -eq 0 ]; then 7 | usermod -u $www_uid wpsnapshots 2> /dev/null 8 | groupmod -g $www_gid wpsnapshots 2> /dev/null 9 | fi 10 | fi 11 | 12 | exec su - wpsnapshots -c "cd /var/www/html; /opt/wpsnapshots/bin/wpsnapshots $*" 13 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/bootstrap.php: -------------------------------------------------------------------------------- 1 | add( new Command\Configure() ); 27 | $app->add( new Command\Create() ); 28 | $app->add( new Command\CreateRepository() ); 29 | $app->add( new Command\Push() ); 30 | $app->add( new Command\Pull() ); 31 | $app->add( new Command\Search() ); 32 | $app->add( new Command\Delete() ); 33 | $app->add( new Command\Download() ); 34 | 35 | $app->run(); 36 | -------------------------------------------------------------------------------- /src/classes/Command/Configure.php: -------------------------------------------------------------------------------- 1 | setName( 'configure' ); 33 | $this->setDescription( 'Configure WP Snapshots with an existing repository.' ); 34 | $this->addArgument( 'repository', InputArgument::REQUIRED, 'Repository to configure.' ); 35 | $this->addOption( 'region', null, InputOption::VALUE_REQUIRED, 'AWS region to use.' ); 36 | $this->addOption( 'aws_key', null, InputOption::VALUE_REQUIRED, 'AWS Access Key ID.' ); 37 | $this->addOption( 'aws_secret', null, InputOption::VALUE_REQUIRED, 'AWS Secret Access Key.' ); 38 | $this->addOption( 'user_name', null, InputOption::VALUE_REQUIRED, 'Your Name.' ); 39 | $this->addOption( 'user_email', null, InputOption::VALUE_REQUIRED, 'Your Email.' ); 40 | } 41 | 42 | /** 43 | * Execute command 44 | * 45 | * @param InputInterface $input Command input 46 | * @param OutputInterface $output Command output 47 | */ 48 | protected function execute( InputInterface $input, OutputInterface $output ) { 49 | Log::instance()->setOutput( $output ); 50 | 51 | $repository = $input->getArgument( 'repository' ); 52 | 53 | $region = $input->getOption( 'region' ); 54 | $access_key_id = $input->getOption( 'aws_key' ); 55 | $secret_access_key = $input->getOption( 'aws_secret' ); 56 | 57 | if ( empty( $region ) ) { 58 | $region = 'us-west-1'; 59 | } 60 | 61 | $config = Config::get(); 62 | 63 | if ( ! empty( $config['repositories'][ $repository ] ) ) { 64 | Log::instance()->write( 'Repository config already exists. Proceeding will overwrite it.' ); 65 | } 66 | 67 | $repo_config = [ 68 | 'repository' => $repository, 69 | ]; 70 | 71 | $helper = $this->getHelper( 'question' ); 72 | 73 | $i = 0; 74 | 75 | /** 76 | * Loop until we get S3 credentials that work 77 | */ 78 | while ( true ) { 79 | 80 | if ( 0 < $i || empty( $access_key_id ) ) { 81 | $access_key_id = $helper->ask( $input, $output, new Question( 'AWS Access Key ID: ' ) ); 82 | } 83 | 84 | if ( 0 < $i || empty( $secret_access_key ) ) { 85 | $secret_access_key = $helper->ask( $input, $output, new Question( 'AWS Secret Access Key: ' ) ); 86 | } 87 | 88 | $repo_config['access_key_id'] = $access_key_id; 89 | $repo_config['secret_access_key'] = $secret_access_key; 90 | $repo_config['region'] = $region; 91 | 92 | $test = S3::test( $repo_config ); 93 | 94 | if ( true !== $test ) { 95 | break; 96 | } else { 97 | if ( 'InvalidAccessKeyId' === $test ) { 98 | Log::instance()->write( 'Repository connection did not work. Try again?', 0, 'warning' ); 99 | } elseif ( 'NoSuchBucket' === $test ) { 100 | Log::instance()->write( 'We successfully connected to AWS. However, no repository has been created. Run `wpsnapshots create-repository` after configuration is complete.', 0, 'warning' ); 101 | break; 102 | } else { 103 | break; 104 | } 105 | } 106 | 107 | $i++; 108 | } 109 | 110 | $name = $input->getOption( 'user_name' ); 111 | $email = $input->getOption( 'user_email' ); 112 | 113 | if ( empty( $name ) ) { 114 | $name_question = new Question( 'Your Name: ' ); 115 | $name_question->setValidator( '\WPSnapshots\Utils\not_empty_validator' ); 116 | $name = $helper->ask( $input, $output, $name_question ); 117 | } 118 | 119 | $config['name'] = $name; 120 | 121 | if ( empty( $email ) ) { 122 | $email = $helper->ask( $input, $output, new Question( 'Your Email: ' ) ); 123 | } 124 | 125 | if ( ! empty( $email ) ) { 126 | $config['email'] = $email; 127 | } 128 | 129 | $create_dir = Utils\create_snapshot_directory(); 130 | 131 | if ( ! $create_dir ) { 132 | Log::instance()->write( 'Cannot create necessary snapshot directory.', 0, 'error' ); 133 | 134 | return 1; 135 | } 136 | 137 | $repositories = $config['repositories']; 138 | 139 | $repositories[ $repository ] = $repo_config; 140 | 141 | $config['repositories'] = $repositories; 142 | 143 | $config->write(); 144 | 145 | Log::instance()->write( 'WP Snapshots configuration verified and saved.', 0, 'success' ); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/classes/Command/Create.php: -------------------------------------------------------------------------------- 1 | setName( 'create' ); 34 | $this->setDescription( 'Create a snapshot locally.' ); 35 | $this->addOption( 'exclude', false, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Exclude a file or directory from the snapshot.' ); 36 | $this->addOption( 'exclude_uploads', false, InputOption::VALUE_NONE, 'Exclude uploads from pushed snapshot.' ); 37 | $this->addOption( 'repository', null, InputOption::VALUE_REQUIRED, 'Repository to use. Defaults to first repository saved in config.' ); 38 | $this->addOption( 'small', false, InputOption::VALUE_NONE, 'Trim data and files to create a small snapshot. Note that this action will modify your local.' ); 39 | $this->addOption( 'include_files', null, InputOption::VALUE_OPTIONAL, 'Include files in snapshot.', false ); 40 | $this->addOption( 'include_db', null, InputOption::VALUE_OPTIONAL, 'Include database in snapshot.', false ); 41 | 42 | $this->addOption( 'slug', null, InputOption::VALUE_REQUIRED, 'Project slug for snapshot.' ); 43 | $this->addOption( 'description', null, InputOption::VALUE_OPTIONAL, 'Description of snapshot.' ); 44 | $this->addOption( 'no_scrub', false, InputOption::VALUE_NONE, "Don't scrub personal user data. This is a legacy option and equivalent to --scrub=0" ); 45 | $this->addOption( 'scrub', false, InputOption::VALUE_REQUIRED, 'Scrubbing to do on data. 2 is the most aggressive and replaces all user information with dummy data; 1 only replaces passwords; 0 is no scrubbing. Defaults to 2.', 2 ); 46 | 47 | $this->addOption( 'path', null, InputOption::VALUE_REQUIRED, 'Path to WordPress files.' ); 48 | $this->addOption( 'db_host', null, InputOption::VALUE_REQUIRED, 'Database host.' ); 49 | $this->addOption( 'db_name', null, InputOption::VALUE_REQUIRED, 'Database name.' ); 50 | $this->addOption( 'db_user', null, InputOption::VALUE_REQUIRED, 'Database user.' ); 51 | $this->addOption( 'db_password', null, InputOption::VALUE_REQUIRED, 'Database password.' ); 52 | 53 | $this->addOption( 'wp_version', null, InputOption::VALUE_OPTIONAL, 'Override the WordPress version.' ); 54 | } 55 | 56 | /** 57 | * Executes the command 58 | * 59 | * @param InputInterface $input Command input 60 | * @param OutputInterface $output Command output 61 | */ 62 | protected function execute( InputInterface $input, OutputInterface $output ) { 63 | Log::instance()->setOutput( $output ); 64 | 65 | $repository = RepositoryManager::instance()->setup( $input->getOption( 'repository' ) ); 66 | 67 | if ( ! $repository ) { 68 | Log::instance()->write( 'Could not setup repository.', 0, 'error' ); 69 | return 1; 70 | } 71 | 72 | $path = $input->getOption( 'path' ); 73 | 74 | if ( empty( $path ) ) { 75 | $path = getcwd(); 76 | } 77 | 78 | $path = Utils\normalize_path( $path ); 79 | 80 | $helper = $this->getHelper( 'question' ); 81 | 82 | $project = $input->getOption( 'slug' ); 83 | 84 | if ( ! empty( $project ) ) { 85 | $project = preg_replace( '#[^a-zA-Z0-9\-_]#', '', $project ); 86 | } 87 | 88 | if ( empty( $project ) ) { 89 | $project_question = new Question( 'Project Slug (letters, numbers, _, and - only): ' ); 90 | $project_question->setValidator( '\WPSnapshots\Utils\slug_validator' ); 91 | 92 | $project = $helper->ask( $input, $output, $project_question ); 93 | } 94 | 95 | $description = $input->getOption( 'description' ); 96 | 97 | if ( ! isset( $description ) ) { 98 | $description_question = new Question( 'Snapshot Description (e.g. Local environment): ' ); 99 | $description_question->setValidator( '\WPSnapshots\Utils\not_empty_validator' ); 100 | 101 | $description = $helper->ask( $input, $output, $description_question ); 102 | } 103 | 104 | $exclude = $input->getOption( 'exclude' ); 105 | 106 | if ( ! empty( $input->getOption( 'exclude_uploads' ) ) ) { 107 | $exclude[] = './uploads'; 108 | } 109 | 110 | $files = $input->getOption( 'include_files' ); 111 | if ( false === $files ) { 112 | $files_question = new ConfirmationQuestion( 'Include files in snapshot? (Y/n) ', true ); 113 | 114 | $include_files = $helper->ask( $input, $output, $files_question ); 115 | } else { 116 | $include_files = is_null( $files ) || filter_var( $files, FILTER_VALIDATE_BOOLEAN ); // is_null( $files ) when `--include_files` is used without a value 117 | } 118 | 119 | $database = $input->getOption( 'include_db' ); 120 | if ( false === $database ) { 121 | $db_question = new ConfirmationQuestion( 'Include database in snapshot? (Y/n) ', true ); 122 | 123 | $include_db = $helper->ask( $input, $output, $db_question ); 124 | } else { 125 | $include_db = is_null( $database ) || filter_var( $database, FILTER_VALIDATE_BOOLEAN ); // is_null( $database ) when `--include_db` is used without a value 126 | } 127 | 128 | if ( empty( $include_files ) && empty( $include_db ) ) { 129 | Log::instance()->write( 'A snapshot must include either a database or a snapshot.', 0, 'error' ); 130 | return 1; 131 | } 132 | 133 | $scrub = $input->getOption( 'scrub' ); 134 | 135 | if ( $input->getOption( 'no_scrub' ) ) { 136 | $scrub = 0; 137 | } 138 | 139 | $snapshot = Snapshot::create( 140 | [ 141 | 'db_host' => $input->getOption( 'db_host' ), 142 | 'db_name' => $input->getOption( 'db_name' ), 143 | 'db_user' => $input->getOption( 'db_user' ), 144 | 'db_password' => $input->getOption( 'db_password' ), 145 | 'project' => $project, 146 | 'path' => $path, 147 | 'description' => $description, 148 | 'scrub' => (int) $scrub, 149 | 'small' => $input->getOption( 'small' ), 150 | 'exclude' => $exclude, 151 | 'repository' => $repository->getName(), 152 | 'contains_db' => $include_db, 153 | 'contains_files' => $include_files, 154 | 'wp_version' => $input->getOption( 'wp_version' ), 155 | ], 156 | $output, 157 | $input->getOption( 'verbose' ) 158 | ); 159 | 160 | if ( is_a( $snapshot, '\WPSnapshots\Snapshot' ) ) { 161 | Log::instance()->write( 'Create finished! Snapshot ID is ' . $snapshot->id, 0, 'success' ); 162 | } else { 163 | return 1; 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/classes/Command/CreateRepository.php: -------------------------------------------------------------------------------- 1 | setName( 'create-repository' ); 32 | $this->setDescription( 'Create new WP Snapshots repository.' ); 33 | $this->addArgument( 'repository', InputArgument::REQUIRED, 'Repository to create.' ); 34 | } 35 | 36 | /** 37 | * Executes the command 38 | * 39 | * @param InputInterface $input Command input 40 | * @param OutputInterface $output Command output 41 | */ 42 | protected function execute( InputInterface $input, OutputInterface $output ) { 43 | Log::instance()->setOutput( $output ); 44 | 45 | $repository = RepositoryManager::instance()->setup( $input->getArgument( 'repository' ) ); 46 | 47 | if ( ! $repository ) { 48 | Log::instance()->write( 'Repository not configured. Before creating the repository, you must configure. Run `wpsnapshots configure ' . $repository . '`', 0, 'error' ); 49 | return 1; 50 | } 51 | 52 | $create_s3 = $repository->getS3()->createBucket(); 53 | 54 | $s3_setup = true; 55 | 56 | if ( true !== $create_s3 ) { 57 | if ( 'BucketExists' === $create_s3 || 'BucketAlreadyOwnedByYou' === $create_s3 || 'BucketAlreadyExists' === $create_s3 ) { 58 | Log::instance()->write( 'S3 already setup.', 0, 'warning' ); 59 | } else { 60 | Log::instance()->write( 'Could not create S3 bucket.', 0, 'error' ); 61 | 62 | $s3_setup = false; 63 | } 64 | } 65 | 66 | $create_db = $repository->getDB()->createTables(); 67 | 68 | $db_setup = true; 69 | 70 | if ( true !== $create_db ) { 71 | if ( 'ResourceInUseException' === $create_db ) { 72 | Log::instance()->write( 'DynamoDB table already setup.', 0, 'warning' ); 73 | } else { 74 | Log::instance()->write( 'Could not create DynamoDB table.', 0, 'error' ); 75 | 76 | $db_setup = false; 77 | } 78 | } 79 | 80 | if ( ! $db_setup || ! $s3_setup ) { 81 | Log::instance()->write( 'Repository could not be created.', 0, 'error' ); 82 | return 1; 83 | } else { 84 | Log::instance()->write( 'Repository setup!', 0, 'success' ); 85 | } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/classes/Command/Delete.php: -------------------------------------------------------------------------------- 1 | setName( 'delete' ); 32 | $this->setDescription( 'Delete a snapshot from the repository.' ); 33 | $this->addArgument( 'snapshot_id', InputArgument::REQUIRED, 'Snapshot ID to delete.' ); 34 | $this->addOption( 'repository', null, InputOption::VALUE_REQUIRED, 'Repository to use. Defaults to first repository saved in config.' ); 35 | } 36 | 37 | /** 38 | * Execute command 39 | * 40 | * @param InputInterface $input Command input 41 | * @param OutputInterface $output Command output 42 | */ 43 | protected function execute( InputInterface $input, OutputInterface $output ) { 44 | Log::instance()->setOutput( $output ); 45 | 46 | $repository = RepositoryManager::instance()->setup( $input->getOption( 'repository' ) ); 47 | 48 | if ( ! $repository ) { 49 | Log::instance()->write( 'Could not setup repository.', 0, 'error' ); 50 | return 1; 51 | } 52 | 53 | $id = $input->getArgument( 'snapshot_id' ); 54 | 55 | $snapshot = $repository->getDB()->getSnapshot( $id ); 56 | 57 | if ( ! $snapshot ) { 58 | Log::instance()->write( 'Could not get snapshot from database.', 0, 'error' ); 59 | 60 | return 1; 61 | } 62 | 63 | $files_result = $repository->getS3()->deleteSnapshot( $id, $snapshot['project'] ); 64 | 65 | if ( ! $files_result ) { 66 | Log::instance()->write( 'Could not delete snapshot.', 0, 'error' ); 67 | 68 | return 1; 69 | } 70 | 71 | $db_result = $repository->getDB()->deleteSnapshot( $id ); 72 | 73 | if ( ! $db_result ) { 74 | Log::instance()->write( 'Could not delete snapshot.', 0, 'error' ); 75 | 76 | return 1; 77 | } 78 | 79 | Log::instance()->write( 'Snapshot deleted.', 0, 'success' ); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/classes/Command/Download.php: -------------------------------------------------------------------------------- 1 | setName( 'download' ); 34 | $this->setDescription( 'Download a snapshot from the repository.' ); 35 | $this->addArgument( 'snapshot_id', InputArgument::REQUIRED, 'Snapshot ID to download.' ); 36 | $this->addOption( 'repository', null, InputOption::VALUE_REQUIRED, 'Repository to use. Defaults to first repository saved in config.' ); 37 | $this->addOption( 'include_files', null, InputOption::VALUE_OPTIONAL, 'Include files in snapshot.', false ); 38 | $this->addOption( 'include_db', null, InputOption::VALUE_OPTIONAL, 'Include database in snapshot.', false ); 39 | } 40 | 41 | /** 42 | * Executes the command 43 | * 44 | * @param InputInterface $input Command input 45 | * @param OutputInterface $output Command output 46 | */ 47 | protected function execute( InputInterface $input, OutputInterface $output ) { 48 | Log::instance()->setOutput( $output ); 49 | 50 | $repository = RepositoryManager::instance()->setup( $input->getOption( 'repository' ) ); 51 | 52 | if ( ! $repository ) { 53 | Log::instance()->write( 'Could not setup repository.', 0, 'error' ); 54 | return 1; 55 | } 56 | 57 | $id = $input->getArgument( 'snapshot_id' ); 58 | 59 | if ( empty( $path ) ) { 60 | $path = getcwd(); 61 | } 62 | 63 | $remote_meta = Meta::getRemote( $id, $repository->getName() ); 64 | 65 | if ( empty( $remote_meta ) ) { 66 | Log::instance()->write( 'Snapshot does not exist.', 0, 'error' ); 67 | 68 | return 1; 69 | } 70 | 71 | $helper = $this->getHelper( 'question' ); 72 | 73 | if ( ! empty( $remote_meta['contains_files'] ) && ! empty( $remote_meta['contains_db'] ) ) { 74 | $files = $input->getOption( 'include_files' ); 75 | if ( false === $files ) { 76 | $files_question = new ConfirmationQuestion( 'Do you want to download snapshot files? (Y/n) ', true ); 77 | 78 | $include_files = $helper->ask( $input, $output, $files_question ); 79 | } else { 80 | $include_files = is_null( $files ) || filter_var( $files, FILTER_VALIDATE_BOOLEAN ); // is_null( $files ) when `--include_files` is used without a value 81 | } 82 | 83 | $database = $input->getOption( 'include_db' ); 84 | if ( false === $database ) { 85 | $db_question = new ConfirmationQuestion( 'Do you want to download the snapshot database? (Y/n) ', true ); 86 | 87 | $include_db = $helper->ask( $input, $output, $db_question ); 88 | } else { 89 | $include_db = is_null( $database ) || filter_var( $database, FILTER_VALIDATE_BOOLEAN ); // is_null( $database ) when `--include_db` is used without a value 90 | } 91 | } else { 92 | $include_db = true; 93 | $include_files = true; 94 | } 95 | 96 | $local_meta = Meta::getLocal( $id, $repository->getName() ); 97 | 98 | if ( ! empty( $local_meta ) && $local_meta['contains_files'] === $include_files && $local_meta['contains_db'] === $include_db ) { 99 | $overwrite_snapshot = $helper->ask( $input, $output, new ConfirmationQuestion( 'This snapshot exists locally. Do you want to overwrite it? (Y/n) ', true ) ); 100 | 101 | if ( empty( $overwrite_snapshot ) ) { 102 | Log::instance()->write( 'No action needed.', 0, 'success' ); 103 | 104 | return 0; 105 | } 106 | } 107 | 108 | $snapshot = Snapshot::getRemote( $id, $repository->getName(), ! $include_files, ! $include_db ); 109 | 110 | if ( is_a( $snapshot, '\WPSnapshots\Snapshot' ) ) { 111 | Log::instance()->write( 'Download finished!', 0, 'success' ); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/classes/Command/Push.php: -------------------------------------------------------------------------------- 1 | setName( 'push' ); 36 | $this->setDescription( 'Push a snapshot to a repository.' ); 37 | $this->addArgument( 'snapshot_id', InputArgument::OPTIONAL, 'Optional snapshot ID to push. If none is provided, a new snapshot will be created from the local environment.' ); 38 | $this->addOption( 'repository', null, InputOption::VALUE_REQUIRED, 'Repository to use. Defaults to first repository saved in config.' ); 39 | $this->addOption( 'small', false, InputOption::VALUE_NONE, 'Trim data and files to create a small snapshot. Note that this action will modify your local.' ); 40 | $this->addOption( 'include_files', null, InputOption::VALUE_OPTIONAL, 'Include files in snapshot.', false ); 41 | $this->addOption( 'include_db', null, InputOption::VALUE_OPTIONAL, 'Include database in snapshot.', false ); 42 | 43 | $this->addOption( 'slug', null, InputOption::VALUE_REQUIRED, 'Project slug for snapshot.' ); 44 | $this->addOption( 'description', null, InputOption::VALUE_OPTIONAL, 'Description of snapshot.' ); 45 | $this->addOption( 'no_scrub', false, InputOption::VALUE_NONE, "Don't scrub personal user data. This is a legacy option and equivalent to --scrub=0" ); 46 | $this->addOption( 'scrub', false, InputOption::VALUE_REQUIRED, 'Scrubbing to do on data. 2 is the most aggressive and replaces all user information with dummy data; 1 only replaces passwords; 0 is no scrubbing. Defaults to 2.', 2 ); 47 | 48 | $this->addOption( 'path', null, InputOption::VALUE_REQUIRED, 'Path to WordPress files.' ); 49 | $this->addOption( 'db_host', null, InputOption::VALUE_REQUIRED, 'Database host.' ); 50 | $this->addOption( 'db_name', null, InputOption::VALUE_REQUIRED, 'Database name.' ); 51 | $this->addOption( 'db_user', null, InputOption::VALUE_REQUIRED, 'Database user.' ); 52 | $this->addOption( 'db_password', null, InputOption::VALUE_REQUIRED, 'Database password.' ); 53 | $this->addOption( 'exclude', false, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Exclude a file or directory from the snapshot.' ); 54 | $this->addOption( 'exclude_uploads', false, InputOption::VALUE_NONE, 'Exclude uploads from pushed snapshot.' ); 55 | 56 | $this->addOption( 'wp_version', null, InputOption::VALUE_OPTIONAL, 'Override the WordPress version.' ); 57 | } 58 | /** 59 | * Executes the command 60 | * 61 | * @param InputInterface $input Command input 62 | * @param OutputInterface $output Command output 63 | */ 64 | protected function execute( InputInterface $input, OutputInterface $output ) { 65 | Log::instance()->setOutput( $output ); 66 | 67 | $repository = RepositoryManager::instance()->setup( $input->getOption( 'repository' ) ); 68 | 69 | if ( ! $repository ) { 70 | Log::instance()->write( 'Could not setup repository.', 0, 'error' ); 71 | return 1; 72 | } 73 | 74 | $output->writeln( 'Security Warning: WP Snapshots creates copies of your codebase and database. This could result in data retention policy issues, please exercise extreme caution when using production data.' ); 75 | 76 | $snapshot_id = $input->getArgument( 'snapshot_id' ); 77 | 78 | if ( ! empty( $snapshot_id ) ) { 79 | $remote_snapshot = Meta::getRemote( $snapshot_id, $repository->getName() ); 80 | 81 | if ( ! empty( $remote_snapshot ) ) { 82 | Log::instance()->write( 'You can not overwrite an existing snapshot. Please create a new one.', 0, 'error' ); 83 | 84 | return 1; 85 | } 86 | 87 | $local_snapshot = Meta::getLocal( $snapshot_id, $repository->getName() ); 88 | 89 | if ( empty( $local_snapshot ) ) { 90 | Log::instance()->write( 'Snapshot not found locally.', 0, 'error' ); 91 | 92 | return 1; 93 | } 94 | 95 | $snapshot = Snapshot::getLocal( $snapshot_id, $repository->getName() ); 96 | } else { 97 | 98 | $path = $input->getOption( 'path' ); 99 | 100 | if ( empty( $path ) ) { 101 | $path = getcwd(); 102 | } 103 | 104 | $path = Utils\normalize_path( $path ); 105 | 106 | $helper = $this->getHelper( 'question' ); 107 | 108 | $verbose = $input->getOption( 'verbose' ); 109 | 110 | $project = $input->getOption( 'slug' ); 111 | 112 | if ( ! empty( $project ) ) { 113 | $project = preg_replace( '#[^a-zA-Z0-9\-_]#', '', $project ); 114 | } 115 | 116 | if ( empty( $project ) ) { 117 | $project_question = new Question( 'Project Slug (letters, numbers, _, and - only): ' ); 118 | $project_question->setValidator( '\WPSnapshots\Utils\slug_validator' ); 119 | 120 | $project = $helper->ask( $input, $output, $project_question ); 121 | } 122 | 123 | $description = $input->getOption( 'description' ); 124 | 125 | if ( ! isset( $description ) ) { 126 | $description_question = new Question( 'Snapshot Description (e.g. Local environment): ' ); 127 | $description_question->setValidator( '\WPSnapshots\Utils\not_empty_validator' ); 128 | 129 | $description = $helper->ask( $input, $output, $description_question ); 130 | } 131 | 132 | $exclude = $input->getOption( 'exclude' ); 133 | 134 | if ( ! empty( $input->getOption( 'exclude_uploads' ) ) ) { 135 | $exclude[] = './uploads'; 136 | } 137 | 138 | $files = $input->getOption( 'include_files' ); 139 | if ( false === $files ) { 140 | $files_question = new ConfirmationQuestion( 'Include files in snapshot? (Y/n) ', true ); 141 | 142 | $include_files = $helper->ask( $input, $output, $files_question ); 143 | } else { 144 | $include_files = is_null( $files ) || filter_var( $files, FILTER_VALIDATE_BOOLEAN ); // is_null( $files ) when `--include_files` is used without a value 145 | } 146 | 147 | $database = $input->getOption( 'include_db' ); 148 | if ( false === $database ) { 149 | $db_question = new ConfirmationQuestion( 'Include database in snapshot? (Y/n) ', true ); 150 | 151 | $include_db = $helper->ask( $input, $output, $db_question ); 152 | } else { 153 | $include_db = is_null( $database ) || filter_var( $database, FILTER_VALIDATE_BOOLEAN ); // is_null( $database ) when `--include_db` is used without a value 154 | } 155 | 156 | if ( empty( $include_files ) && empty( $include_db ) ) { 157 | Log::instance()->write( 'A snapshot must include either a database or a snapshot.', 0, 'error' ); 158 | return 1; 159 | } 160 | 161 | $scrub = $input->getOption( 'scrub' ); 162 | 163 | if ( $input->getOption( 'no_scrub' ) ) { 164 | $scrub = 0; 165 | } 166 | 167 | $snapshot = Snapshot::create( 168 | [ 169 | 'path' => $path, 170 | 'db_host' => $input->getOption( 'db_host' ), 171 | 'db_name' => $input->getOption( 'db_name' ), 172 | 'db_user' => $input->getOption( 'db_user' ), 173 | 'db_password' => $input->getOption( 'db_password' ), 174 | 'project' => $project, 175 | 'description' => $description, 176 | 'scrub' => (int) $scrub, 177 | 'small' => $input->getOption( 'small' ), 178 | 'exclude' => $exclude, 179 | 'repository' => $repository->getName(), 180 | 'contains_db' => $include_db, 181 | 'contains_files' => $include_files, 182 | 'wp_version' => $input->getOption( 'wp_version' ), 183 | ], $output, $verbose 184 | ); 185 | } 186 | 187 | if ( ! is_a( $snapshot, '\WPSnapshots\Snapshot' ) ) { 188 | return 1; 189 | } 190 | 191 | if ( $snapshot->push() ) { 192 | Log::instance()->write( 'Push finished!' . ( empty( $snapshot_id ) ? ' Snapshot ID is ' . $snapshot->id : '' ), 0, 'success' ); 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/classes/Command/Search.php: -------------------------------------------------------------------------------- 1 | setName( 'search' ); 33 | $this->setDescription( 'Search for snapshots within a repository.' ); 34 | $this->addArgument( 'search_text', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'Text to search against snapshots. If multiple queries are used, they must match exactly to project names or snapshot ids.' ); 35 | $this->addOption( 'repository', null, InputOption::VALUE_REQUIRED, 'Repository to use. Defaults to first repository saved in config.' ); 36 | $this->addOption( 'format', null, InputOption::VALUE_OPTIONAL, 'Render output in a particular format. Available options: table and json. Defaults to table.', 'table' ); 37 | } 38 | 39 | /** 40 | * Executes the command 41 | * 42 | * @param InputInterface $input Command input 43 | * @param OutputInterface $output Command output 44 | */ 45 | protected function execute( InputInterface $input, OutputInterface $output ) { 46 | Log::instance()->setOutput( $output ); 47 | 48 | $repository = RepositoryManager::instance()->setup( $input->getOption( 'repository' ) ); 49 | 50 | if ( ! $repository ) { 51 | Log::instance()->write( 'Could not setup repository.', 0, 'error' ); 52 | return 1; 53 | } 54 | 55 | $instances = $repository->getDB()->search( $input->getArgument( 'search_text' ) ); 56 | 57 | if ( false === $instances ) { 58 | Log::instance()->write( 'An error occured while searching.', 0, 'success' ); 59 | } 60 | 61 | if ( empty( $instances ) ) { 62 | Log::instance()->write( 'No snapshots found.', 0, 'warning' ); 63 | return; 64 | } 65 | 66 | $rows = []; 67 | $output_format = $input->getOption( 'format' ); 68 | 69 | $date_format = 'F j, Y, g:i a'; 70 | if ( 'json' === $output_format ) { 71 | $date_format = 'U'; 72 | } 73 | 74 | foreach ( $instances as $instance ) { 75 | if ( empty( $instance['time'] ) ) { 76 | $instance['time'] = time(); 77 | } 78 | 79 | // Defaults to yes for backwards compat since old snapshots dont have this meta. 80 | $contains_files = 'Yes'; 81 | $contains_db = 'Yes'; 82 | 83 | if ( isset( $instance['contains_files'] ) ) { 84 | $contains_files = $instance['contains_files'] ? 'Yes' : 'No'; 85 | } 86 | 87 | if ( isset( $instance['contains_db'] ) ) { 88 | $contains_db = $instance['contains_db'] ? 'Yes' : 'No'; 89 | } 90 | 91 | $size = '-'; 92 | 93 | if ( empty( $instance['files_size'] ) && empty( $instance['db_size'] ) ) { 94 | // This is for backwards compat with old snapshots 95 | if ( ! empty( $instance['size'] ) ) { 96 | $size = Utils\format_bytes( (int) $instance['size'] ); 97 | } 98 | } else { 99 | $size = 0; 100 | 101 | if ( ! empty( $instance['files_size'] ) ) { 102 | $size += (int) $instance['files_size']; 103 | } if ( ! empty( $instance['db_size'] ) ) { 104 | $size += (int) $instance['db_size']; 105 | } 106 | 107 | $size = Utils\format_bytes( $size ); 108 | } 109 | 110 | $rows[ $instance['time'] ] = [ 111 | 'id' => ( ! empty( $instance['id'] ) ) ? $instance['id'] : '', 112 | 'project' => ( ! empty( $instance['project'] ) ) ? $instance['project'] : '', 113 | 'contains_files' => $contains_files, 114 | 'contains_db' => $contains_db, 115 | 'description' => ( ! empty( $instance['description'] ) ) ? $instance['description'] : '', 116 | 'author' => ( ! empty( $instance['author']['name'] ) ) ? $instance['author']['name'] : '', 117 | 'size' => $size, 118 | 'multisite' => ( ! empty( $instance['multisite'] ) ) ? 'Yes' : 'No', 119 | 'created' => ( ! empty( $instance['time'] ) ) ? date( $date_format, $instance['time'] ) : '', 120 | ]; 121 | } 122 | 123 | ksort( $rows ); 124 | 125 | switch( $output_format ) { 126 | case 'json': 127 | $io = new SymfonyStyle( $input, $output ); 128 | $io->write( json_encode( array_values( $rows ) ) . PHP_EOL ); 129 | break; 130 | default: 131 | $table = new Table( $output ); 132 | $table->setHeaders( [ 'ID', 'Project', 'Files', 'Database', 'Description', 'Author', 'Size', 'Multisite', 'Created' ] ); 133 | $table->setRows( $rows ); 134 | $table->render(); 135 | break; 136 | } 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /src/classes/Config.php: -------------------------------------------------------------------------------- 1 | config = array_merge( 32 | [ 33 | 'name' => '', 34 | 'email' => '', 35 | 'repositories' => [], 36 | ], 37 | $config 38 | ); 39 | } 40 | 41 | /** 42 | * Create config from file 43 | * 44 | * @return Config|bool 45 | */ 46 | public static function get() { 47 | Log::instance()->write( 'Reading configuration from file.', 1 ); 48 | 49 | $file_path = Utils\get_snapshot_directory() . 'config.json'; 50 | 51 | if ( ! file_exists( $file_path ) ) { 52 | /** 53 | * Backwards compat for old config file path 54 | */ 55 | $file_path = $_SERVER['HOME'] . '/.wpsnapshots.json'; 56 | 57 | if ( ! file_exists( $file_path ) ) { 58 | Log::instance()->write( 'No config found.', 1 ); 59 | 60 | $config = new self(); 61 | $config->write(); 62 | 63 | return $config; 64 | } else { 65 | rename( $file_path, Utils\get_snapshot_directory() . 'config.json' ); 66 | 67 | $file_path = Utils\get_snapshot_directory() . 'config.json'; 68 | } 69 | } 70 | 71 | $config = json_decode( file_get_contents( $file_path ), true ); 72 | 73 | if ( empty( $config ) ) { 74 | $config = []; 75 | } 76 | 77 | return new self( $config ); 78 | } 79 | 80 | /** 81 | * Write config to current config file 82 | */ 83 | public function write() { 84 | Log::instance()->write( 'Writing config.', 1 ); 85 | 86 | $create_dir = Utils\create_snapshot_directory(); 87 | 88 | if ( ! $create_dir ) { 89 | Log::instance()->write( 'Cannot create necessary snapshot directory.', 0, 'error' ); 90 | 91 | return false; 92 | } 93 | 94 | file_put_contents( Utils\get_snapshot_directory() . 'config.json', json_encode( $this->config, JSON_PRETTY_PRINT ) ); 95 | } 96 | 97 | /** 98 | * Set key in class 99 | * 100 | * @param int|string $offset Array key 101 | * @param mixed $value Array value 102 | */ 103 | public function offsetSet( $offset, $value ) { 104 | if ( is_null( $offset ) ) { 105 | $this->config[] = $value; 106 | } else { 107 | $this->config[ $offset ] = $value; 108 | } 109 | } 110 | 111 | /** 112 | * Check if key exists 113 | * 114 | * @param int|string $offset Array key 115 | * @return bool 116 | */ 117 | public function offsetExists( $offset ) { 118 | return isset( $this->config[ $offset ] ); 119 | } 120 | 121 | /** 122 | * Delete array value by key 123 | * 124 | * @param int|string $offset Array key 125 | */ 126 | public function offsetUnset( $offset ) { 127 | unset( $this->config[ $offset ] ); 128 | } 129 | 130 | /** 131 | * Get config array 132 | * 133 | * @return array 134 | */ 135 | public function toArray() { 136 | return $this->config; 137 | } 138 | 139 | /** 140 | * Get array value by key 141 | * 142 | * @param int|string $offset Array key 143 | * @return mixed 144 | */ 145 | public function offsetGet( $offset ) { 146 | return isset( $this->config[ $offset ] ) ? $this->config[ $offset ] : null; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/classes/DB.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 63 | $this->access_key_id = $access_key_id; 64 | $this->secret_access_key = $secret_access_key; 65 | $this->region = $region; 66 | 67 | $this->client = DynamoDbClient::factory( 68 | [ 69 | 'credentials' => [ 70 | 'key' => $access_key_id, 71 | 'secret' => $secret_access_key, 72 | ], 73 | 'region' => $region, 74 | 'version' => '2012-08-10', 75 | 'csm' => false, 76 | ] 77 | ); 78 | } 79 | 80 | /** 81 | * Use DynamoDB scan to search tables for snapshots where project, id, or author information 82 | * matches search text. Searching for "*" returns all snapshots. 83 | * 84 | * @param string|array $query Search query string 85 | * @return array 86 | */ 87 | public function search( $query ) { 88 | $marshaler = new Marshaler(); 89 | 90 | $args = [ 91 | 'TableName' => 'wpsnapshots-' . $this->repository, 92 | ]; 93 | 94 | if ( ! is_array( $query ) ) { 95 | $query = [ $query ]; 96 | } 97 | 98 | if ( ! in_array( '*', $query ) ) { 99 | $attribute_value_list = array_map( function( $text ) { 100 | return [ 'S' => strtolower( $text ) ]; 101 | }, $query ); 102 | 103 | $is_multiple_queries = count( $attribute_value_list ) > 1; 104 | 105 | $args['ConditionalOperator'] = 'OR'; 106 | $args['ScanFilter'] = [ 107 | 'project' => [ 108 | 'AttributeValueList' => $attribute_value_list, 109 | 'ComparisonOperator' => $is_multiple_queries ? 'IN' : 'CONTAINS', 110 | ], 111 | 'id' => [ 112 | 'AttributeValueList' => $attribute_value_list, 113 | 'ComparisonOperator' => $is_multiple_queries ? 'IN' : 'EQ', 114 | ], 115 | ]; 116 | } 117 | 118 | try { 119 | $search_scan = $this->client->getIterator( 'Scan', $args ); 120 | } catch ( \Exception $e ) { 121 | Log::instance()->write( 'Error Message: ' . $e->getMessage(), 1, 'error' ); 122 | Log::instance()->write( 'AWS Request ID: ' . $e->getAwsRequestId(), 1, 'error' ); 123 | Log::instance()->write( 'AWS Error Type: ' . $e->getAwsErrorType(), 1, 'error' ); 124 | Log::instance()->write( 'AWS Error Code: ' . $e->getAwsErrorCode(), 1, 'error' ); 125 | 126 | return false; 127 | } 128 | 129 | $instances = []; 130 | 131 | foreach ( $search_scan as $item ) { 132 | $instances[] = $marshaler->unmarshalItem( $item ); 133 | } 134 | 135 | return $instances; 136 | } 137 | 138 | /** 139 | * Insert a snapshot into the DB 140 | * 141 | * @param Snapshot $snapshot Snapshot to insert 142 | * @return array|bool 143 | */ 144 | public function insertSnapshot( Snapshot $snapshot ) { 145 | $marshaler = new Marshaler(); 146 | 147 | $snapshot_item = [ 148 | 'project' => strtolower( $snapshot->meta['project'] ), 149 | 'id' => $snapshot->id, 150 | 'time' => time(), 151 | ]; 152 | 153 | $snapshot_item = array_merge( $snapshot_item, $snapshot->meta->toArray() ); 154 | $snapshot_json = json_encode( $snapshot_item ); 155 | 156 | try { 157 | $result = $this->client->putItem( 158 | [ 159 | 'TableName' => 'wpsnapshots-' . $this->repository, 160 | 'Item' => $marshaler->marshalJson( $snapshot_json ), 161 | ] 162 | ); 163 | } catch ( \Exception $e ) { 164 | if ( 'AccessDeniedException' === $e->getAwsErrorCode() ) { 165 | Log::instance()->write( 'Access denied. You might not have access to this project.', 0, 'error' ); 166 | } 167 | 168 | Log::instance()->write( 'Error Message: ' . $e->getMessage(), 1, 'error' ); 169 | Log::instance()->write( 'AWS Request ID: ' . $e->getAwsRequestId(), 1, 'error' ); 170 | Log::instance()->write( 'AWS Error Type: ' . $e->getAwsErrorType(), 1, 'error' ); 171 | Log::instance()->write( 'AWS Error Code: ' . $e->getAwsErrorCode(), 1, 'error' ); 172 | 173 | return false; 174 | } 175 | 176 | return $snapshot_item; 177 | } 178 | 179 | /** 180 | * Delete a snapshot given an id 181 | * 182 | * @param string $id Snapshot ID 183 | * @return bool|Error 184 | */ 185 | public function deleteSnapshot( $id ) { 186 | try { 187 | $result = $this->client->deleteItem( 188 | [ 189 | 'TableName' => 'wpsnapshots-' . $this->repository, 190 | 'Key' => [ 191 | 'id' => [ 192 | 'S' => $id, 193 | ], 194 | ], 195 | ] 196 | ); 197 | } catch ( \Exception $e ) { 198 | Log::instance()->write( 'Error Message: ' . $e->getMessage(), 1, 'error' ); 199 | Log::instance()->write( 'AWS Request ID: ' . $e->getAwsRequestId(), 1, 'error' ); 200 | Log::instance()->write( 'AWS Error Type: ' . $e->getAwsErrorType(), 1, 'error' ); 201 | Log::instance()->write( 'AWS Error Code: ' . $e->getAwsErrorCode(), 1, 'error' ); 202 | 203 | return false; 204 | } 205 | 206 | return true; 207 | } 208 | 209 | /** 210 | * Get a snapshot given an id 211 | * 212 | * @param string $id Snapshot ID 213 | * @return bool 214 | */ 215 | public function getSnapshot( $id ) { 216 | try { 217 | $result = $this->client->getItem( 218 | [ 219 | 'ConsistentRead' => true, 220 | 'TableName' => 'wpsnapshots-' . $this->repository, 221 | 'Key' => [ 222 | 'id' => [ 223 | 'S' => $id, 224 | ], 225 | ], 226 | ] 227 | ); 228 | } catch ( \Exception $e ) { 229 | if ( 'AccessDeniedException' === $e->getAwsErrorCode() ) { 230 | Log::instance()->write( 'Access denied. You might not have access to this snapshot.', 0, 'error' ); 231 | } 232 | 233 | Log::instance()->write( 'Error Message: ' . $e->getMessage(), 1, 'error' ); 234 | Log::instance()->write( 'AWS Request ID: ' . $e->getAwsRequestId(), 1, 'error' ); 235 | Log::instance()->write( 'AWS Error Type: ' . $e->getAwsErrorType(), 1, 'error' ); 236 | Log::instance()->write( 'AWS Error Code: ' . $e->getAwsErrorCode(), 1, 'error' ); 237 | 238 | return false; 239 | } 240 | 241 | if ( empty( $result['Item'] ) ) { 242 | return false; 243 | } 244 | 245 | if ( ! empty( $result['Item']['error'] ) ) { 246 | return false; 247 | } 248 | 249 | $marshaler = new Marshaler(); 250 | 251 | return $marshaler->unmarshalItem( $result['Item'] ); 252 | } 253 | 254 | /** 255 | * Create default DB tables. Only need to do this once ever for repo setup. 256 | * 257 | * @return bool 258 | */ 259 | public function createTables() { 260 | try { 261 | $this->client->createTable( 262 | [ 263 | 'TableName' => 'wpsnapshots-' . $this->repository, 264 | 'AttributeDefinitions' => [ 265 | [ 266 | 'AttributeName' => 'id', 267 | 'AttributeType' => 'S', 268 | ], 269 | ], 270 | 'KeySchema' => [ 271 | [ 272 | 'AttributeName' => 'id', 273 | 'KeyType' => 'HASH', 274 | ], 275 | ], 276 | 'ProvisionedThroughput' => [ 277 | 'ReadCapacityUnits' => 10, 278 | 'WriteCapacityUnits' => 20, 279 | ], 280 | ] 281 | ); 282 | 283 | $this->client->waitUntil( 284 | 'TableExists', [ 285 | 'TableName' => 'wpsnapshots-' . $this->repository, 286 | ] 287 | ); 288 | } catch ( \Exception $e ) { 289 | Log::instance()->write( 'Error Message: ' . $e->getMessage(), 1, 'error' ); 290 | Log::instance()->write( 'AWS Request ID: ' . $e->getAwsRequestId(), 1, 'error' ); 291 | Log::instance()->write( 'AWS Error Type: ' . $e->getAwsErrorType(), 1, 'error' ); 292 | Log::instance()->write( 'AWS Error Code: ' . $e->getAwsErrorCode(), 1, 'error' ); 293 | 294 | return $e->getAwsErrorCode(); 295 | } 296 | 297 | return true; 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/classes/Log.php: -------------------------------------------------------------------------------- 1 | output = $output; 56 | 57 | if ( $output->isDebug() ) { 58 | $this->verbosity = 3; 59 | } elseif ( $output->isVeryVerbose() ) { 60 | $this->verbosity = 2; 61 | } elseif ( $output->isVerbose() ) { 62 | $this->verbosity = 1; 63 | } 64 | } 65 | 66 | /** 67 | * Verbosity offset lets us make normal output verbose if we are using this logger 68 | * within another application. 69 | * 70 | * @param int $verbosity_offset Offset number 71 | */ 72 | public function setVerbosityOffset( $verbosity_offset ) { 73 | $this->verbosity_offset = (int) $verbosity_offset; 74 | } 75 | 76 | /** 77 | * Write to log 78 | * 79 | * @param string $message String to write 80 | * @param int $verbosity_level Verbosity level. See https://symfony.com/doc/current/console/verbosity.html 81 | * @param string $type Either 'info', 'success', 'warning', 'error' 82 | * @param array $data Arbitrary data to write 83 | * @return array 84 | */ 85 | public function write( $message, $verbosity_level = 0, $type = 'info', $data = [] ) { 86 | $verbosity_level += $this->verbosity_offset; 87 | 88 | $entry = [ 89 | 'message' => $message, 90 | 'data' => $data, 91 | 'type' => $type, 92 | 'verbosity_level' => $verbosity_level, 93 | ]; 94 | 95 | $this->log[] = $entry; 96 | 97 | if ( ! empty( $this->output ) ) { 98 | if ( 'warning' === $type ) { 99 | $message = '' . $message . ''; 100 | } elseif ( 'success' === $type ) { 101 | $message = '' . $message . ''; 102 | } elseif ( 'error' === $type ) { 103 | $message = '' . $message . ''; 104 | } 105 | 106 | $console_verbosity_level = OutputInterface::VERBOSITY_NORMAL; 107 | 108 | if ( 1 === $verbosity_level ) { 109 | $console_verbosity_level = OutputInterface::VERBOSITY_VERBOSE; 110 | } elseif ( 2 === $verbosity_level ) { 111 | $console_verbosity_level = OutputInterface::VERBOSITY_VERY_VERBOSE; 112 | } elseif ( 3 === $verbosity_level ) { 113 | $console_verbosity_level = OutputInterface::VERBOSITY_DEBUG; 114 | } 115 | 116 | $this->output->writeln( $message, $console_verbosity_level ); 117 | } 118 | 119 | return $entry; 120 | } 121 | 122 | /** 123 | * Get verbosity of output 124 | * 125 | * @return bool 126 | */ 127 | public function getVerbosity() { 128 | return $this->verbosity; 129 | } 130 | 131 | /** 132 | * Return singleton instance of class 133 | * 134 | * @return object 135 | */ 136 | public static function instance() { 137 | static $instance; 138 | 139 | if ( empty( $instance ) ) { 140 | $instance = new self(); 141 | } 142 | 143 | return $instance; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/classes/Meta.php: -------------------------------------------------------------------------------- 1 | meta = $meta; 42 | $this->id = $id; 43 | } 44 | 45 | /** 46 | * Save snapshot meta locally 47 | * 48 | * @return false|int Number of bytes written 49 | */ 50 | public function saveLocal() { 51 | $meta_handle = @fopen( Utils\get_snapshot_directory() . $this->id . '/meta.json', 'x' ); // Create file and fail if it exists. 52 | 53 | if ( ! $meta_handle ) { 54 | return false; 55 | } 56 | 57 | return fwrite( $meta_handle, json_encode( $this->meta, JSON_PRETTY_PRINT ) ); 58 | } 59 | 60 | /** 61 | * Get meta. First try locally then try downloading 62 | * 63 | * @param string $id Snapshot id 64 | * @param string $repository_name Name of repo 65 | * @return bool|Meta 66 | */ 67 | public static function get( $id, $repository_name ) { 68 | $cached_meta = self::getLocal( $id, $repository_name ); 69 | 70 | // Maybe meta has already been downloaded. 71 | if ( ! empty( $cached_meta ) ) { 72 | return $cached_meta; 73 | } 74 | 75 | return self::getRemote( $id, $repository_name ); 76 | } 77 | 78 | /** 79 | * Download meta from remote DB 80 | * 81 | * @param string $id Snapshot id 82 | * @param string $repository_name Name of repo 83 | * @return bool|Meta 84 | */ 85 | public static function getRemote( $id, $repository_name ) { 86 | $repository = RepositoryManager::instance()->setup( $repository_name ); 87 | 88 | if ( ! $repository ) { 89 | Log::instance()->write( 'Could not setup repository.', 0, 'error' ); 90 | 91 | return false; 92 | } 93 | 94 | $snapshot = $repository->getDB()->getSnapshot( $id ); 95 | 96 | if ( ! $snapshot ) { 97 | Log::instance()->write( 'Could not download snapshot meta from database.', 0, 'error' ); 98 | 99 | return false; 100 | } 101 | 102 | // Backwards compat since these previously were not set. 103 | if ( ! isset( $snapshot['contains_files'] ) ) { 104 | $snapshot['contains_files'] = true; 105 | } if ( ! isset( $snapshot['contains_db'] ) ) { 106 | $snapshot['contains_db'] = true; 107 | } 108 | 109 | $snapshot['repository'] = $repository_name; 110 | 111 | return new self( $id, $snapshot ); 112 | } 113 | 114 | /** 115 | * Get local snapshot meta 116 | * 117 | * @param string $id Snapshot ID 118 | * @param string $repository_name Snapshot repository 119 | * @return self|bool 120 | */ 121 | public static function getLocal( $id, $repository_name ) { 122 | if ( ! file_exists( Utils\get_snapshot_directory() . $id . '/meta.json' ) ) { 123 | return false; 124 | } 125 | 126 | $meta_file_contents = file_get_contents( Utils\get_snapshot_directory() . $id . '/meta.json' ); 127 | $meta = json_decode( $meta_file_contents, true ); 128 | 129 | if ( null === $meta ) { 130 | Log::instance()->write( 'Could not decode snapshot meta.', 0, 'error' ); 131 | 132 | return false; 133 | } 134 | 135 | if ( $repository_name !== $meta['repository'] ) { 136 | return false; 137 | } 138 | 139 | // Backwards compat since these previously were not set. 140 | if ( ! isset( $meta['contains_files'] ) && file_exists( Utils\get_snapshot_directory() . $id . '/files.tar.gz' ) ) { 141 | $meta['contains_files'] = true; 142 | } if ( ! isset( $meta['contains_db'] ) && file_exists( Utils\get_snapshot_directory() . $id . '/data.sql.gz' ) ) { 143 | $meta['contains_db'] = true; 144 | } 145 | 146 | if ( empty( $meta['contains_files'] ) && empty( $meta['contains_db'] ) ) { 147 | Log::instance()->write( 'Snapshot meta invalid.', 0, 'error' ); 148 | 149 | return false; 150 | } 151 | 152 | return new self( $id, $meta ); 153 | } 154 | 155 | /** 156 | * Set key in class 157 | * 158 | * @param int|string $offset Array key 159 | * @param mixed $value Array value 160 | */ 161 | public function offsetSet( $offset, $value ) { 162 | if ( is_null( $offset ) ) { 163 | $this->meta[] = $value; 164 | } else { 165 | $this->meta[ $offset ] = $value; 166 | } 167 | } 168 | 169 | /** 170 | * Check if key exists 171 | * 172 | * @param int|string $offset Array key 173 | * @return bool 174 | */ 175 | public function offsetExists( $offset ) { 176 | return isset( $this->meta[ $offset ] ); 177 | } 178 | 179 | /** 180 | * Delete array value by key 181 | * 182 | * @param int|string $offset Array key 183 | */ 184 | public function offsetUnset( $offset ) { 185 | unset( $this->meta[ $offset ] ); 186 | } 187 | 188 | /** 189 | * Get meta array 190 | * 191 | * @return array 192 | */ 193 | public function toArray() { 194 | return $this->meta; 195 | } 196 | 197 | /** 198 | * Get array value by key 199 | * 200 | * @param int|string $offset Array key 201 | * @return mixed 202 | */ 203 | public function offsetGet( $offset ) { 204 | return isset( $this->meta[ $offset ] ) ? $this->meta[ $offset ] : null; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/classes/Repository.php: -------------------------------------------------------------------------------- 1 | name = $name; 46 | 47 | $this->s3 = new S3( $name, $access_key_id, $secret_access_key, $region ); 48 | 49 | $this->db = new DB( $name, $access_key_id, $secret_access_key, $region ); 50 | } 51 | 52 | /** 53 | * Get DB client 54 | * 55 | * @return DB 56 | */ 57 | public function getDB() { 58 | return $this->db; 59 | } 60 | 61 | /** 62 | * Get S3 client 63 | * 64 | * @return S3 65 | */ 66 | public function getS3() { 67 | return $this->s3; 68 | } 69 | 70 | /** 71 | * Get repository name 72 | * 73 | * @return string 74 | */ 75 | public function getName() { 76 | return $this->name; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/classes/RepositoryManager.php: -------------------------------------------------------------------------------- 1 | config['repositories'] ) ) { 39 | Log::instance()->write( 'No repositories in configuration.', 1 ); 40 | 41 | return false; 42 | } 43 | 44 | if ( empty( $repository_name ) ) { 45 | $repository_name = $this->getDefault(); 46 | } 47 | 48 | if ( ! empty( $this->repositories[ $repository_name ] ) ) { 49 | return $this->repositories[ $repository_name ]; 50 | } 51 | 52 | if ( empty( $this->config['repositories'][ $repository_name ] ) ) { 53 | Log::instance()->write( 'Repository not in configuration.', 1 ); 54 | 55 | return false; 56 | } 57 | 58 | $repo_config = $this->config['repositories'][ $repository_name ]; 59 | 60 | $repository = new Repository( $repository_name, $repo_config['access_key_id'], $repo_config['secret_access_key'], $repo_config['region'] ); 61 | 62 | $this->repositories[ $repository_name ] = $repository; 63 | 64 | Log::instance()->write( 'Setup repository: ' . $repository_name, 1 ); 65 | 66 | return $repository; 67 | } 68 | 69 | /** 70 | * Get default repository 71 | * 72 | * @return string|bool 73 | */ 74 | public function getDefault() { 75 | if ( empty( $this->config['repositories'] ) ) { 76 | return false; 77 | } 78 | 79 | $repos = $this->config['repositories']; 80 | 81 | if ( 1 === count( $repos ) && ! empty( $repos['local'] ) ) { 82 | return 'local'; 83 | } 84 | 85 | if ( ! empty( $repos['local'] ) ) { 86 | unset( $repos['local'] ); 87 | } 88 | 89 | $repos = array_values( $repos ); 90 | 91 | return $repos[0]['repository']; 92 | } 93 | 94 | /** 95 | * Setup repo manager 96 | */ 97 | private function __construct() { 98 | $this->config = Config::get(); 99 | 100 | // Add local repository 101 | $repositories = []; 102 | 103 | if ( empty( $this->config ) || empty( $this->repositories ) ) { 104 | $repositories = $this->config['repositories']; 105 | } 106 | 107 | $repositories['local'] = [ 108 | 'repository' => 'local', 109 | 'access_key_id' => '', 110 | 'secret_access_key' => '', 111 | 'region' => '', 112 | ]; 113 | 114 | $this->config['repositories'] = $repositories; 115 | } 116 | 117 | /** 118 | * Get config 119 | * 120 | * @return Config 121 | */ 122 | public function getConfig() { 123 | return $this->config; 124 | } 125 | 126 | /** 127 | * Get author info 128 | * 129 | * @return array|bool 130 | */ 131 | public function getAuthorInfo() { 132 | if ( empty( $this->config ) ) { 133 | return false; 134 | } 135 | 136 | return [ 137 | 'name' => $this->config['name'], 138 | 'email' => $this->config['email'], 139 | ]; 140 | } 141 | 142 | /** 143 | * Return singleton instance of class 144 | * 145 | * @return object 146 | */ 147 | public static function instance() { 148 | static $instance; 149 | 150 | if ( empty( $instance ) ) { 151 | $instance = new self(); 152 | } 153 | 154 | return $instance; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/classes/S3.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 63 | $this->access_key_id = $access_key_id; 64 | $this->secret_access_key = $secret_access_key; 65 | $this->region = $region; 66 | 67 | $this->client = S3Client::factory( 68 | [ 69 | 'credentials' => [ 70 | 'key' => $access_key_id, 71 | 'secret' => $secret_access_key, 72 | ], 73 | 'signature' => 'v4', 74 | 'region' => $region, 75 | 'version' => '2006-03-01', 76 | 'csm' => false, 77 | ] 78 | ); 79 | } 80 | 81 | /** 82 | * Upload a snapshot to S3 83 | * 84 | * @param Snapshot $snapshot Snapshot to push 85 | * @return bool|error 86 | */ 87 | public function putSnapshot( Snapshot $snapshot ) { 88 | try { 89 | $files_result = null; 90 | 91 | if ( $snapshot->meta['contains_db'] ) { 92 | $db_result = $this->client->putObject( 93 | [ 94 | 'Bucket' => self::getBucketName( $this->repository ), 95 | 'Key' => $snapshot->meta['project'] . '/' . $snapshot->id . '/data.sql.gz', 96 | 'SourceFile' => realpath( Utils\get_snapshot_directory() . $snapshot->id . '/data.sql.gz' ), 97 | ] 98 | ); 99 | } 100 | 101 | if ( $snapshot->meta['contains_files'] ) { 102 | $files_result = $this->client->putObject( 103 | [ 104 | 'Bucket' => self::getBucketName( $this->repository ), 105 | 'Key' => $snapshot->meta['project'] . '/' . $snapshot->id . '/files.tar.gz', 106 | 'SourceFile' => realpath( Utils\get_snapshot_directory() . $snapshot->id . '/files.tar.gz' ), 107 | ] 108 | ); 109 | } 110 | 111 | /** 112 | * Wait for files first since that will probably take longer 113 | */ 114 | if ( $snapshot->meta['contains_files'] ) { 115 | $this->client->waitUntil( 116 | 'ObjectExists', [ 117 | 'Bucket' => self::getBucketName( $this->repository ), 118 | 'Key' => $snapshot->meta['project'] . '/' . $snapshot->id . '/files.tar.gz', 119 | ] 120 | ); 121 | } 122 | 123 | if ( $snapshot->meta['contains_db'] ) { 124 | $this->client->waitUntil( 125 | 'ObjectExists', [ 126 | 'Bucket' => self::getBucketName( $this->repository ), 127 | 'Key' => $snapshot->meta['project'] . '/' . $snapshot->id . '/data.sql.gz', 128 | ] 129 | ); 130 | } 131 | } catch ( \Exception $e ) { 132 | if ( ! empty( $files_result ) && 'AccessDenied' === $files_result->data['aws_error_code'] ) { 133 | Log::instance()->write( 'Access denied. You might not have access to this project.', 0, 'error' ); 134 | } 135 | 136 | Log::instance()->write( 'Error Message: ' . $e->getMessage(), 1, 'error' ); 137 | Log::instance()->write( 'AWS Request ID: ' . $e->getAwsRequestId(), 1, 'error' ); 138 | Log::instance()->write( 'AWS Error Type: ' . $e->getAwsErrorType(), 1, 'error' ); 139 | Log::instance()->write( 'AWS Error Code: ' . $e->getAwsErrorCode(), 1, 'error' ); 140 | 141 | return false; 142 | } 143 | 144 | return true; 145 | } 146 | 147 | /** 148 | * Download a snapshot given an id. Must specify where to download files/data 149 | * 150 | * @param Snapshot $snapshot Snapshot to be downloaded 151 | * @return array|error 152 | */ 153 | public function downloadSnapshot( Snapshot $snapshot ) { 154 | try { 155 | if ( $snapshot->meta['contains_db'] ) { 156 | $db_download = $this->client->getObject( 157 | [ 158 | 'Bucket' => self::getBucketName( $this->repository ), 159 | 'Key' => $snapshot->meta['project'] . '/' . $snapshot->id . '/data.sql.gz', 160 | 'SaveAs' => Utils\get_snapshot_directory() . $snapshot->id . '/data.sql.gz', 161 | ] 162 | ); 163 | } 164 | 165 | if ( $snapshot->meta['contains_files'] ) { 166 | $files_download = $this->client->getObject( 167 | [ 168 | 'Bucket' => self::getBucketName( $this->repository ), 169 | 'Key' => $snapshot->meta['project'] . '/' . $snapshot->id . '/files.tar.gz', 170 | 'SaveAs' => Utils\get_snapshot_directory() . $snapshot->id . '/files.tar.gz', 171 | ] 172 | ); 173 | } 174 | } catch ( \Exception $e ) { 175 | if ( 'AccessDenied' === $e->getAwsErrorCode() ) { 176 | Log::instance()->write( 'Access denied. You might not have access to this snapshot.', 0, 'error' ); 177 | } 178 | 179 | Log::instance()->write( 'Error Message: ' . $e->getMessage(), 1, 'error' ); 180 | Log::instance()->write( 'AWS Request ID: ' . $e->getAwsRequestId(), 1, 'error' ); 181 | Log::instance()->write( 'AWS Error Type: ' . $e->getAwsErrorType(), 1, 'error' ); 182 | Log::instance()->write( 'AWS Error Code: ' . $e->getAwsErrorCode(), 1, 'error' ); 183 | 184 | return false; 185 | } 186 | 187 | return true; 188 | } 189 | 190 | /** 191 | * Delete a snapshot given an id 192 | * 193 | * @param string $id Snapshot id 194 | * @param string $project Project name 195 | * @return bool|error 196 | */ 197 | public function deleteSnapshot( $id, $project ) { 198 | try { 199 | $result = $this->client->deleteObjects( 200 | [ 201 | 'Bucket' => self::getBucketName( $this->repository ), 202 | 'Delete' => [ 203 | 'Objects' => [ 204 | [ 205 | 'Key' => $project . '/' . $id . '/files.tar.gz', 206 | ], 207 | [ 208 | 'Key' => $project . '/' . $id . '/data.sql', 209 | ], 210 | [ 211 | 'Key' => $project . '/' . $id . '/data.sql.gz', 212 | ], 213 | ], 214 | ], 215 | ] 216 | ); 217 | } catch ( \Exception $e ) { 218 | if ( 'AccessDenied' === $s3_add->data['aws_error_code'] ) { 219 | Log::instance()->write( 'Access denied. You might not have access to this snapshot.', 0, 'error' ); 220 | } 221 | 222 | Log::instance()->write( 'Error Message: ' . $e->getMessage(), 1, 'error' ); 223 | Log::instance()->write( 'AWS Request ID: ' . $e->getAwsRequestId(), 1, 'error' ); 224 | Log::instance()->write( 'AWS Error Type: ' . $e->getAwsErrorType(), 1, 'error' ); 225 | Log::instance()->write( 'AWS Error Code: ' . $e->getAwsErrorCode(), 1, 'error' ); 226 | 227 | return false; 228 | } 229 | 230 | return true; 231 | } 232 | 233 | /** 234 | * Get bucket name 235 | * 236 | * @param string $repository Repository name 237 | * @return string 238 | */ 239 | public static function getBucketName( $repository ) { 240 | return 'wpsnapshots-' . $repository; 241 | } 242 | 243 | /** 244 | * Test S3 connection by attempting to list S3 objects. 245 | * 246 | * @param array $config Config array 247 | * @return bool|integer 248 | */ 249 | public static function test( $config ) { 250 | $client = S3Client::factory( 251 | [ 252 | 'credentials' => [ 253 | 'key' => $config['access_key_id'], 254 | 'secret' => $config['secret_access_key'], 255 | ], 256 | 'signature' => 'v4', 257 | 'region' => $config['region'], 258 | 'version' => '2006-03-01', 259 | ] 260 | ); 261 | 262 | $bucket_name = self::getBucketName( $config['repository'] ); 263 | 264 | try { 265 | $objects = $client->listObjects( [ 'Bucket' => $bucket_name ] ); 266 | } catch ( \Exception $e ) { 267 | Log::instance()->write( 'Error Message: ' . $e->getMessage(), 1, 'error' ); 268 | Log::instance()->write( 'AWS Request ID: ' . $e->getAwsRequestId(), 1, 'error' ); 269 | Log::instance()->write( 'AWS Error Type: ' . $e->getAwsErrorType(), 1, 'error' ); 270 | Log::instance()->write( 'AWS Error Code: ' . $e->getAwsErrorCode(), 1, 'error' ); 271 | 272 | return $e->getAwsErrorCode(); 273 | } 274 | 275 | return true; 276 | } 277 | 278 | /** 279 | * Create WP Snapshots S3 bucket 280 | * 281 | * @return bool|string 282 | */ 283 | public function createBucket() { 284 | $bucket_exists = false; 285 | 286 | try { 287 | $result = $this->client->listBuckets(); 288 | } catch ( \Exception $e ) { 289 | Log::instance()->write( 'Error Message: ' . $e->getMessage(), 1, 'error' ); 290 | Log::instance()->write( 'AWS Request ID: ' . $e->getAwsRequestId(), 1, 'error' ); 291 | Log::instance()->write( 'AWS Error Type: ' . $e->getAwsErrorType(), 1, 'error' ); 292 | Log::instance()->write( 'AWS Error Code: ' . $e->getAwsErrorCode(), 1, 'error' ); 293 | 294 | return $e->getAwsErrorCode(); 295 | } 296 | 297 | $bucket_name = self::getBucketName( $this->repository ); 298 | 299 | foreach ( $result['Buckets'] as $bucket ) { 300 | if ( $bucket_name === $bucket['Name'] ) { 301 | $bucket_exists = true; 302 | } 303 | } 304 | 305 | if ( $bucket_exists ) { 306 | return 'BucketExists'; 307 | } 308 | 309 | try { 310 | $result = $this->client->createBucket( 311 | [ 312 | 'Bucket' => self::getBucketName( $this->repository ), 313 | 'LocationConstraint' => $this->region, 314 | ] 315 | ); 316 | } catch ( \Exception $e ) { 317 | Log::instance()->write( 'Error Message: ' . $e->getMessage(), 1, 'error' ); 318 | Log::instance()->write( 'AWS Request ID: ' . $e->getAwsRequestId(), 1, 'error' ); 319 | Log::instance()->write( 'AWS Error Type: ' . $e->getAwsErrorType(), 1, 'error' ); 320 | Log::instance()->write( 'AWS Error Code: ' . $e->getAwsErrorCode(), 1, 'error' ); 321 | 322 | return $e->getAwsErrorCode(); 323 | } 324 | 325 | return true; 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /src/classes/SearchReplace.php: -------------------------------------------------------------------------------- 1 | max_recursion = intval( ini_get( 'xdebug.max_nesting_level' ) ); 35 | $this->old = $old; 36 | $this->new = $new; 37 | $this->updated = 0; 38 | 39 | $skip_columns = [ 40 | 'user_pass', 41 | ]; 42 | 43 | // @todo: need to remove this hardcode 44 | $php_only = false; 45 | 46 | foreach ( $tables as $table ) { 47 | list( $primary_keys, $columns, $all_columns ) = $this->get_columns( $table ); 48 | 49 | // since we'll be updating one row at a time, 50 | // we need a primary key to identify the row 51 | if ( empty( $primary_keys ) ) { 52 | continue; 53 | } 54 | 55 | $table_sql = $this->esc_sql_ident( $table ); 56 | 57 | foreach ( $columns as $col ) { 58 | if ( in_array( $col, $skip_columns ) ) { 59 | continue; 60 | } 61 | 62 | if ( ! $php_only ) { 63 | $col_sql = $this->esc_sql_ident( $col ); 64 | 65 | $wpdb->last_error = ''; 66 | 67 | $serial_row = $wpdb->get_row( "SELECT * FROM $table_sql WHERE $col_sql REGEXP '^[aiO]:[1-9]' LIMIT 1" ); 68 | 69 | // When the regex triggers an error, we should fall back to PHP 70 | if ( false !== strpos( $wpdb->last_error, 'ERROR 1139' ) ) { 71 | $serial_row = true; 72 | } 73 | } 74 | 75 | if ( $php_only || null !== $serial_row ) { 76 | $updated = $this->php_handle_col( $col, $primary_keys, $table ); 77 | } else { 78 | $updated = $this->sql_handle_col( $col, $table ); 79 | } 80 | 81 | $this->updated += $updated; 82 | } 83 | } 84 | } 85 | 86 | /** 87 | * Get columns for a table organizing by text/primary. 88 | * 89 | * @param string $table Table name 90 | * @return array 91 | */ 92 | private function get_columns( $table ) { 93 | global $wpdb; 94 | 95 | $table_sql = $this->esc_sql_ident( $table ); 96 | $primary_keys = $text_columns = $all_columns = array(); 97 | 98 | foreach ( $wpdb->get_results( "DESCRIBE $table_sql" ) as $col ) { 99 | if ( 'PRI' === $col->Key ) { 100 | $primary_keys[] = $col->Field; 101 | } 102 | 103 | if ( $this->is_text_col( $col->Type ) ) { 104 | $text_columns[] = $col->Field; 105 | } 106 | 107 | $all_columns[] = $col->Field; 108 | } 109 | 110 | return array( $primary_keys, $text_columns, $all_columns ); 111 | } 112 | 113 | /** 114 | * Check if column type is text 115 | * 116 | * @param string $type Column type 117 | * @return boolean 118 | */ 119 | private function is_text_col( $type ) { 120 | foreach ( array( 'text', 'varchar' ) as $token ) { 121 | if ( false !== strpos( $type, $token ) ) { 122 | return true; 123 | } 124 | } 125 | 126 | return false; 127 | } 128 | 129 | /** 130 | * Escape SQL identifiers i.e. table name, column name with backticks 131 | * 132 | * @param string|array $idents Identifiers 133 | * @return string|array 134 | */ 135 | private function esc_sql_ident( $idents ) { 136 | $backtick = function( $v ) { 137 | // Escape any backticks in the identifier by doubling. 138 | return '`' . str_replace( '`', '``', $v ) . '`'; 139 | }; 140 | 141 | if ( is_string( $idents ) ) { 142 | return $backtick( $idents ); 143 | } 144 | 145 | return array_map( $backtick, $idents ); 146 | } 147 | 148 | /** 149 | * Handle search/replace with only SQL. This won't work for serialized data. Returns 150 | * number of updates made. 151 | * 152 | * @param string $col Column name 153 | * @param string $table Table name 154 | * @return int 155 | */ 156 | private function sql_handle_col( $col, $table ) { 157 | global $wpdb; 158 | 159 | $table_sql = $this->esc_sql_ident( $table ); 160 | $col_sql = $this->esc_sql_ident( $col ); 161 | 162 | $count = $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $this->old, $this->new ) ); 163 | 164 | return $count; 165 | } 166 | 167 | /** 168 | * Use PHP to run search and replace on a column. This is mostly used for serialized data. 169 | * Returns number of updates made 170 | * 171 | * @param string $col Column name 172 | * @param array $primary_keys Array of keys 173 | * @param string $table Table name 174 | * @return int 175 | */ 176 | private function php_handle_col( $col, $primary_keys, $table ) { 177 | global $wpdb; 178 | 179 | $count = 0; 180 | 181 | $table_sql = $this->esc_sql_ident( $table ); 182 | $col_sql = $this->esc_sql_ident( $col ); 183 | 184 | $where = " WHERE $col_sql" . $wpdb->prepare( ' LIKE %s', '%' . $wpdb->esc_like( $this->old ) . '%' ); 185 | $primary_keys_sql = implode( ',', $this->esc_sql_ident( $primary_keys ) ); 186 | 187 | $rows = $wpdb->get_results( "SELECT {$primary_keys_sql} FROM {$table_sql} {$where}" ); 188 | 189 | foreach ( $rows as $keys ) { 190 | $where_sql = ''; 191 | 192 | foreach ( (array) $keys as $k => $v ) { 193 | if ( strlen( $where_sql ) ) { 194 | $where_sql .= ' AND '; 195 | } 196 | $where_sql .= $this->esc_sql_ident( $k ) . ' = ' . esc_sql( $v ); 197 | } 198 | 199 | $col_value = $wpdb->get_var( "SELECT {$col_sql} FROM {$table_sql} WHERE {$where_sql}" ); 200 | 201 | if ( '' === $col_value ) { 202 | continue; 203 | } 204 | 205 | $value = $this->php_search_replace( $col_value, false ); 206 | 207 | if ( $value === $col_value ) { 208 | continue; 209 | } 210 | 211 | $where = array(); 212 | 213 | foreach ( (array) $keys as $k => $v ) { 214 | $where[ $k ] = $v; 215 | } 216 | 217 | $count += $wpdb->update( $table, array( $col => $value ), $where ); 218 | } 219 | 220 | return $count; 221 | } 222 | 223 | /** 224 | * Perform php search and replace on data. Returns updated data 225 | * 226 | * @param string|object|array $data Data to search 227 | * @param bool $serialised Serialized data or not 228 | * @param integer $recursion_level Recursion level 229 | * @param array $visited_data Array of visited data 230 | * @return string|object|array 231 | */ 232 | private function php_search_replace( $data, $serialised, $recursion_level = 0, $visited_data = array() ) { 233 | // some unseriliased data cannot be re-serialised eg. SimpleXMLElements 234 | try { 235 | // If we've reached the maximum recursion level, short circuit 236 | if ( $this->max_recursion !== 0 && $recursion_level >= $this->max_recursion ) { // @codingStandardsIgnoreLine 237 | return $data; 238 | } 239 | if ( is_array( $data ) || is_object( $data ) ) { 240 | // If we've seen this exact object or array before, short circuit 241 | if ( in_array( $data, $visited_data, true ) ) { 242 | return $data; // Avoid infinite loops when there's a cycle 243 | } 244 | // Add this data to the list of 245 | $visited_data[] = $data; 246 | } 247 | 248 | if ( is_string( $data ) && ( $unserialized = @unserialize( $data ) ) !== false ) { 249 | $data = $this->php_search_replace( $unserialized, true, $recursion_level + 1 ); 250 | } elseif ( is_array( $data ) ) { 251 | $keys = array_keys( $data ); 252 | foreach ( $keys as $key ) { 253 | $data[ $key ] = $this->php_search_replace( $data[ $key ], false, $recursion_level + 1, $visited_data ); 254 | } 255 | } elseif ( is_object( $data ) ) { 256 | foreach ( $data as $key => $value ) { 257 | $data->$key = $this->php_search_replace( $value, false, $recursion_level + 1, $visited_data ); 258 | } 259 | } elseif ( is_string( $data ) ) { 260 | $data = str_replace( $this->old, $this->new, $data ); 261 | } 262 | if ( $serialised ) { 263 | return serialize( $data ); 264 | } 265 | } catch ( Exception $error ) { 266 | // Do nothing 267 | } 268 | 269 | return $data; 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/classes/Snapshot.php: -------------------------------------------------------------------------------- 1 | id = $id; 58 | $this->repository_name = $repository_name; 59 | $this->remote = $remote; 60 | 61 | if ( is_a( $meta, '\WPSnapshots\Meta' ) ) { 62 | $this->meta = $meta; 63 | } else { 64 | $this->meta = new Meta( $id, $meta ); 65 | } 66 | } 67 | 68 | /** 69 | * Get a snapshot. First try locally and then remote. 70 | * 71 | * @param string $id Snapshot id 72 | * @param string $repository_name Name of repo 73 | * @param bool $no_files If set to true, files will not be downloaded even if available. Defaults to false. 74 | * @param bool $no_db If set to true, db will not be downloaded even if available. Defaults to false. 75 | * @return bool|Snapshot 76 | */ 77 | public static function get( $id, $repository_name, $no_files = false, $no_db = false ) { 78 | $local_snapshot = self::getLocal( $id, $repository_name, $no_files, $no_db ); 79 | 80 | if ( ! empty( $local_snapshot ) ) { 81 | Log::instance()->write( 'Snapshot found in cache.' ); 82 | 83 | return $local_snapshot; 84 | } 85 | 86 | return self::getRemote( $id, $repository_name, $no_files, $no_db ); 87 | } 88 | 89 | /** 90 | * Given an ID, create a WP Snapshots object 91 | * 92 | * @param string $id Snapshot id 93 | * @param string $repository_name Name of repo 94 | * @param bool $no_files If set to true, files will not be downloaded even if available. Defaults to false. 95 | * @param bool $no_db If set to true, db will not be downloaded even if available. Defaults to false. 96 | * @return bool|Snapshot 97 | */ 98 | public static function getLocal( $id, $repository_name, $no_files = false, $no_db = false ) { 99 | if ( $no_files && $no_db ) { 100 | Log::instance()->write( 'Either files or database must be in snapshot.', 0, 'error' ); 101 | 102 | return false; 103 | } 104 | 105 | $meta = Meta::getLocal( $id, $repository_name ); 106 | 107 | if ( empty( $meta ) ) { 108 | return false; 109 | } 110 | 111 | if ( $no_files ) { 112 | $meta['contains_files'] = false; 113 | } 114 | 115 | if ( $no_db ) { 116 | $meta['contains_db'] = false; 117 | } 118 | 119 | return new self( $id, $repository_name, $meta ); 120 | } 121 | 122 | /** 123 | * Create a snapshot. 124 | * 125 | * @param array $args List of arguments 126 | * @return bool|Snapshot 127 | */ 128 | public static function create( $args ) { 129 | $path = Utils\normalize_path( $args['path'] ); 130 | 131 | if ( ! Utils\is_wp_present( $path ) ) { 132 | Log::instance()->write( 'This is not a WordPress install. You can only create a snapshot from the root of a WordPress install.', 0, 'error' ); 133 | 134 | return; 135 | } 136 | 137 | /** 138 | * Define snapshot ID 139 | */ 140 | $id = Utils\generate_snapshot_id(); 141 | 142 | $create_dir = Utils\create_snapshot_directory( $id ); 143 | 144 | if ( ! $create_dir ) { 145 | Log::instance()->write( 'Cannot create necessary snapshot directories.', 0, 'error' ); 146 | 147 | return false; 148 | } 149 | 150 | if ( ! Utils\is_wp_present( $path ) ) { 151 | Log::instance()->write( 'This is not a WordPress install.', 0, 'error' ); 152 | 153 | return false; 154 | } 155 | 156 | if ( ! Utils\locate_wp_config( $path ) ) { 157 | Log::instance()->write( 'No wp-config.php file present.', 0, 'error' ); 158 | 159 | return false; 160 | } 161 | 162 | $extra_config_constants = [ 163 | 'WP_CACHE' => false, 164 | ]; 165 | 166 | if ( ! empty( $args['db_host'] ) ) { 167 | $extra_config_constants['DB_HOST'] = $args['db_host']; 168 | } if ( ! empty( $args['db_name'] ) ) { 169 | $extra_config_constants['DB_NAME'] = $args['db_name']; 170 | } if ( ! empty( $args['db_user'] ) ) { 171 | $extra_config_constants['DB_USER'] = $args['db_user']; 172 | } if ( ! empty( $args['db_password'] ) ) { 173 | $extra_config_constants['DB_PASSWORD'] = $args['db_password']; 174 | } 175 | 176 | Log::instance()->write( 'Bootstrapping WordPress...', 1 ); 177 | 178 | if ( ! WordPressBridge::instance()->load( $path, $extra_config_constants ) ) { 179 | Log::instance()->write( 'Could not connect to WordPress database.', 0, 'error' ); 180 | 181 | return false; 182 | } 183 | 184 | global $wpdb, $wp_version; 185 | 186 | $meta = new Meta( 187 | $id, 188 | [ 189 | 'author' => [], 190 | 'repository' => $args['repository'], 191 | 'description' => $args['description'], 192 | 'project' => $args['project'], 193 | 'contains_files' => $args['contains_files'], 194 | 'contains_db' => $args['contains_db'], 195 | ] 196 | ); 197 | 198 | $meta['wp_version'] = ( ! empty( $wp_version ) ) ? $wp_version : ''; 199 | if ( ! empty( $args['wp_version'] ) ) { 200 | $meta['wp_version'] = $args['wp_version']; 201 | } 202 | 203 | $author_info = RepositoryManager::instance()->getAuthorInfo(); 204 | $author = []; 205 | 206 | if ( ! empty( $author_info['name'] ) ) { 207 | $author['name'] = $author_info['name']; 208 | } 209 | 210 | if ( ! empty( $author_info['email'] ) ) { 211 | $author['email'] = $author_info['email']; 212 | } 213 | 214 | $meta['author'] = $author; 215 | 216 | $verbose_pipe = ( empty( Log::instance()->getVerbosity() ) ) ? '> /dev/null' : ''; 217 | 218 | $snapshot_path = Utils\get_snapshot_directory() . $id . '/'; 219 | 220 | if ( $args['contains_db'] ) { 221 | 222 | if ( ! empty( $args['small'] ) ) { 223 | if ( is_multisite() ) { 224 | $sites = get_sites(); 225 | } else { 226 | Log::instance()->write( 'Trimming snapshot data and files...' ); 227 | } 228 | 229 | while ( true ) { 230 | $prefix = $wpdb->prefix; 231 | 232 | if ( is_multisite() ) { 233 | if ( empty( $sites ) ) { 234 | break; 235 | } 236 | 237 | $site = array_shift( $sites ); 238 | 239 | Log::instance()->write( 'Trimming snapshot data and files blog ' . $site->blog_id . '...' ); 240 | 241 | switch_to_blog( $site->blog_id ); 242 | 243 | $prefix = $wpdb->get_blog_prefix( $site->blog_id ); 244 | } 245 | 246 | // Trim posts 247 | $post_ids = []; 248 | 249 | $post_types_args = [ 250 | 'public' => false, 251 | '_builtin' => true, 252 | ]; 253 | 254 | $post_types = $wpdb->get_results( "SELECT DISTINCT post_type FROM {$prefix}posts", ARRAY_A ); 255 | 256 | if ( ! empty( $post_types ) ) { 257 | 258 | Log::instance()->write( 'Trimming posts...', 1 ); 259 | 260 | foreach ( $post_types as $post_type ) { 261 | $post_type = $post_type['post_type']; 262 | 263 | $posts = $wpdb->get_results( $wpdb->prepare( "SELECT ID FROM {$prefix}posts WHERE post_type='%s' ORDER BY ID DESC LIMIT 300", $post_type ), ARRAY_A ); 264 | 265 | foreach ( $posts as $post ) { 266 | $post_ids[] = (int) $post['ID']; 267 | } 268 | } 269 | 270 | if ( ! empty( $post_ids ) ) { 271 | // Delete other posts 272 | $wpdb->query( "DELETE FROM {$prefix}posts WHERE ID NOT IN (" . implode( ',', $post_ids ) . ')' ); 273 | 274 | // Delete orphan comments 275 | $wpdb->query( "DELETE FROM {$prefix}comments WHERE comment_post_ID NOT IN (" . implode( ',', $post_ids ) . ')' ); 276 | 277 | // Delete orphan meta 278 | $wpdb->query( "DELETE FROM {$prefix}postmeta WHERE post_id NOT IN (" . implode( ',', $post_ids ) . ')' ); 279 | } 280 | } 281 | 282 | Log::instance()->write( 'Trimming comments...', 1 ); 283 | 284 | $comments = $wpdb->get_results( "SELECT comment_ID FROM {$prefix}comments ORDER BY comment_ID DESC LIMIT 500", ARRAY_A ); 285 | 286 | // Delete comments 287 | if ( ! empty( $comments ) ) { 288 | $comment_ids = []; 289 | 290 | foreach ( $comments as $comment ) { 291 | $comment_ids[] = (int) $comment['ID']; 292 | } 293 | 294 | $wpdb->query( "DELETE FROM {$prefix}comments WHERE comment_ID NOT IN (" . implode( ',', $comment_ids ) . ')' ); 295 | 296 | $wpdb->query( "DELETE FROM {$prefix}commentmeta WHERE comment_id NOT IN (" . implode( ',', $comment_ids ) . ')' ); 297 | } 298 | 299 | // Terms 300 | Log::instance()->write( 'Trimming terms...', 1 ); 301 | 302 | $wpdb->query( "DELETE FROM {$prefix}term_relationships WHERE object_id NOT IN (" . implode( ',', array_unique( $post_ids ) ) . ')' ); 303 | 304 | $term_relationships = $wpdb->get_results( "SELECT * FROM {$prefix}term_relationships ORDER BY term_taxonomy_id DESC", ARRAY_A ); 305 | 306 | if ( ! empty( $term_relationships ) ) { 307 | $term_taxonomy_ids = []; 308 | 309 | foreach ( $term_relationships as $term_relationship ) { 310 | $term_taxonomy_ids[] = (int) $term_relationship['term_taxonomy_id']; 311 | } 312 | 313 | $wpdb->query( "DELETE FROM {$prefix}term_taxonomy WHERE term_taxonomy_id NOT IN (" . implode( ',', array_unique( $term_taxonomy_ids ) ) . ')' ); 314 | } 315 | 316 | $term_taxonomy = $wpdb->get_results( "SELECT * FROM {$prefix}term_taxonomy ORDER BY term_taxonomy_id DESC", ARRAY_A ); 317 | 318 | if ( ! empty( $term_taxonomy ) ) { 319 | $term_ids = []; 320 | 321 | foreach ( $term_taxonomy as $term_taxonomy_row ) { 322 | $term_ids[] = (int) $term_taxonomy_row['term_id']; 323 | } 324 | 325 | // Delete excess terms 326 | $wpdb->query( "DELETE FROM {$prefix}terms WHERE term_id NOT IN (" . implode( ',', array_unique( $term_ids ) ) . ')' ); 327 | 328 | // Delete excess term meta 329 | $wpdb->query( "DELETE FROM {$prefix}termmeta WHERE term_id NOT IN (" . implode( ',', array_unique( $term_ids ) ) . ')' ); 330 | } 331 | 332 | if ( is_multisite() ) { 333 | restore_current_blog(); 334 | } else { 335 | break; 336 | } 337 | } 338 | } 339 | 340 | $meta['multisite'] = false; 341 | $meta['subdomain_install'] = false; 342 | $meta['domain_current_site'] = false; 343 | $meta['path_current_site'] = false; 344 | $meta['site_id_current_site'] = false; 345 | $meta['blog_id_current_site'] = false; 346 | 347 | $meta_sites = []; 348 | 349 | if ( is_multisite() ) { 350 | $meta['multisite'] = true; 351 | 352 | if ( defined( 'SUBDOMAIN_INSTALL' ) && SUBDOMAIN_INSTALL ) { 353 | $meta['subdomain_install'] = true; 354 | } 355 | 356 | if ( defined( 'DOMAIN_CURRENT_SITE' ) ) { 357 | $meta['domain_current_site'] = DOMAIN_CURRENT_SITE; 358 | } 359 | 360 | if ( defined( 'PATH_CURRENT_SITE' ) ) { 361 | $meta['path_current_site'] = PATH_CURRENT_SITE; 362 | } 363 | 364 | if ( defined( 'SITE_ID_CURRENT_SITE' ) ) { 365 | $meta['site_id_current_site'] = SITE_ID_CURRENT_SITE; 366 | } 367 | 368 | if ( defined( 'BLOG_ID_CURRENT_SITE' ) ) { 369 | $meta['blog_id_current_site'] = BLOG_ID_CURRENT_SITE; 370 | } 371 | 372 | $sites = get_sites( [ 'number' => 500 ] ); 373 | 374 | foreach ( $sites as $site ) { 375 | $meta_sites[] = [ 376 | 'blog_id' => $site->blog_id, 377 | 'domain' => $site->domain, 378 | 'path' => $site->path, 379 | 'site_url' => get_blog_option( $site->blog_id, 'siteurl' ), 380 | 'home_url' => get_blog_option( $site->blog_id, 'home' ), 381 | 'blogname' => get_blog_option( $site->blog_id, 'blogname' ), 382 | ]; 383 | } 384 | } else { 385 | $meta_sites[] = [ 386 | 'site_url' => get_option( 'siteurl' ), 387 | 'home_url' => get_option( 'home' ), 388 | 'blogname' => get_option( 'blogname' ), 389 | ]; 390 | } 391 | 392 | $meta['sites'] = $meta_sites; 393 | 394 | $main_blog_id = ( defined( 'BLOG_ID_CURRENT_SITE' ) ) ? BLOG_ID_CURRENT_SITE : null; 395 | 396 | $meta['table_prefix'] = $wpdb->get_blog_prefix( $main_blog_id ); 397 | 398 | /** 399 | * Dump sql to .wpsnapshots/data.sql 400 | */ 401 | $command = '/usr/bin/env mysqldump --no-defaults --single-transaction %s'; 402 | $command_esc_args = array( DB_NAME ); 403 | $command .= ' --tables'; 404 | 405 | /** 406 | * We only export tables with WP prefix 407 | */ 408 | Log::instance()->write( 'Getting WordPress tables...', 1 ); 409 | 410 | $tables = Utils\get_tables(); 411 | 412 | foreach ( $tables as $table ) { 413 | // We separate the users/meta table for scrubbing 414 | if ( 0 < $args['scrub'] && $wpdb->users === $table ) { 415 | continue; 416 | } 417 | 418 | if ( 2 === $args['scrub'] && $wpdb->usermeta === $table ) { 419 | continue; 420 | } 421 | 422 | $command .= ' %s'; 423 | $command_esc_args[] = trim( $table ); 424 | } 425 | 426 | $mysql_args = [ 427 | 'host' => DB_HOST, 428 | 'pass' => DB_PASSWORD, 429 | 'user' => DB_USER, 430 | 'result-file' => $snapshot_path . 'data.sql', 431 | ]; 432 | 433 | if ( defined( 'DB_CHARSET' ) && constant( 'DB_CHARSET' ) ) { 434 | $mysql_args['default-character-set'] = constant( 'DB_CHARSET' ); 435 | } 436 | 437 | $escaped_command = call_user_func_array( '\WPSnapshots\Utils\esc_cmd', array_merge( array( $command ), $command_esc_args ) ); 438 | 439 | Log::instance()->write( 'Exporting database...' ); 440 | 441 | Utils\run_mysql_command( $escaped_command, $mysql_args ); 442 | 443 | if ( 1 === $args['scrub'] ) { 444 | 445 | $command = '/usr/bin/env mysqldump --no-defaults --single-transaction %s'; 446 | 447 | $command_esc_args = array( DB_NAME ); 448 | 449 | $command .= ' --tables %s'; 450 | $command_esc_args[] = $wpdb->users; 451 | 452 | $mysql_args = [ 453 | 'host' => DB_HOST, 454 | 'pass' => DB_PASSWORD, 455 | 'user' => DB_USER, 456 | 'result-file' => $snapshot_path . 'data-users.sql', 457 | ]; 458 | 459 | $escaped_command = call_user_func_array( '\WPSnapshots\Utils\esc_cmd', array_merge( array( $command ), $command_esc_args ) ); 460 | 461 | Log::instance()->write( 'Exporting users...', 1 ); 462 | 463 | Utils\run_mysql_command( $escaped_command, $mysql_args ); 464 | 465 | Log::instance()->write( 'Scrubbing user database...' ); 466 | 467 | Log::instance()->write( 'Scrub severity is 1.', 1 ); 468 | 469 | $all_hashed_passwords = []; 470 | $all_emails = []; 471 | 472 | Log::instance()->write( 'Getting users...', 1 ); 473 | 474 | $user_rows = $wpdb->get_results( "SELECT user_pass, user_email FROM $wpdb->users", ARRAY_A ); 475 | 476 | foreach ( $user_rows as $user_row ) { 477 | $all_hashed_passwords[] = $user_row['user_pass']; 478 | if ( $user_row['user_email'] ) { 479 | $all_emails[] = $user_row['user_email']; 480 | } 481 | } 482 | 483 | $sterile_password = wp_hash_password( 'password' ); 484 | $sterile_email = 'user%d@example.com'; 485 | 486 | Log::instance()->write( 'Opening users export...', 1 ); 487 | 488 | $users_handle = @fopen( $snapshot_path . 'data-users.sql', 'r' ); 489 | $data_handle = @fopen( $snapshot_path . 'data.sql', 'a' ); 490 | 491 | if ( ! $users_handle || ! $data_handle ) { 492 | Log::instance()->write( 'Could not scrub users.', 0, 'error' ); 493 | 494 | return false; 495 | } 496 | 497 | $buffer = ''; 498 | $i = 0; 499 | 500 | Log::instance()->write( 'Writing scrubbed user data and merging exports...', 1 ); 501 | 502 | while ( ! feof( $users_handle ) ) { 503 | $chunk = fread( $users_handle, 4096 ); 504 | 505 | foreach ( $all_hashed_passwords as $password ) { 506 | $chunk = str_replace( "'$password'", "'$sterile_password'", $chunk ); 507 | } 508 | 509 | foreach ( $all_emails as $index => $email ) { 510 | $chunk = str_replace( 511 | "'$email'", 512 | sprintf( "'$sterile_email'", $index ), 513 | $chunk 514 | ); 515 | } 516 | 517 | $buffer .= $chunk; 518 | 519 | if ( 0 === $i % 10000 ) { 520 | fwrite( $data_handle, $buffer ); 521 | $buffer = ''; 522 | } 523 | 524 | $i++; 525 | } 526 | 527 | if ( ! empty( $buffer ) ) { 528 | fwrite( $data_handle, $buffer ); 529 | $buffer = ''; 530 | } 531 | 532 | fclose( $data_handle ); 533 | fclose( $users_handle ); 534 | 535 | Log::instance()->write( 'Removing old users SQL...', 1 ); 536 | 537 | unlink( $snapshot_path . 'data-users.sql' ); 538 | } elseif ( 2 === $args['scrub'] ) { 539 | Log::instance()->write( 'Scrubbing users...' ); 540 | 541 | $dummy_users = Utils\get_dummy_users(); 542 | 543 | Log::instance()->write( 'Duplicating users table..', 1 ); 544 | 545 | $wpdb->query( "CREATE TABLE {$wpdb->users}_temp LIKE $wpdb->users" ); 546 | $wpdb->query( "INSERT INTO {$wpdb->users}_temp SELECT * FROM $wpdb->users" ); 547 | 548 | Log::instance()->write( 'Scrub each user record..', 1 ); 549 | 550 | $offset = 0; 551 | 552 | $password = wp_hash_password( 'password' ); 553 | 554 | $user_ids = []; 555 | 556 | while ( true ) { 557 | $users = $wpdb->get_results( $wpdb->prepare( "SELECT ID, user_login FROM {$wpdb->users}_temp LIMIT 1000 OFFSET %d", $offset ), ARRAY_A ); 558 | 559 | if ( empty( $users ) ) { 560 | break; 561 | } 562 | 563 | if ( 1000 <= $offset ) { 564 | usleep( 100 ); 565 | } 566 | 567 | foreach ( $users as $user ) { 568 | $user_id = (int) $user['ID']; 569 | 570 | $user_ids[] = $user_id; 571 | 572 | $dummy_user = $dummy_users[ $user_id % 1000 ]; 573 | 574 | $wpdb->query( 575 | $wpdb->prepare( 576 | "UPDATE {$wpdb->users}_temp SET user_pass=%s, user_email=%s, user_url='', user_activation_key='', display_name=%s WHERE ID=%d", 577 | $password, 578 | $dummy_user['email'], 579 | $user['user_login'], 580 | $user['ID'] 581 | ) 582 | ); 583 | } 584 | 585 | $offset += 1000; 586 | } 587 | 588 | $command = '/usr/bin/env mysqldump --no-defaults --single-transaction %s'; 589 | 590 | $command_esc_args = array( DB_NAME ); 591 | 592 | $command .= ' --tables %s'; 593 | $command_esc_args[] = $wpdb->users . '_temp'; 594 | 595 | $mysql_args = [ 596 | 'host' => DB_HOST, 597 | 'pass' => DB_PASSWORD, 598 | 'user' => DB_USER, 599 | 'result-file' => $snapshot_path . 'data-users.sql', 600 | ]; 601 | 602 | $escaped_command = call_user_func_array( '\WPSnapshots\Utils\esc_cmd', array_merge( array( $command ), $command_esc_args ) ); 603 | 604 | Log::instance()->write( 'Exporting users...', 1 ); 605 | 606 | Utils\run_mysql_command( $escaped_command, $mysql_args ); 607 | 608 | $users_sql = file_get_contents( $snapshot_path . 'data-users.sql' ); 609 | 610 | Log::instance()->write( 'Duplicating user meta table..', 1 ); 611 | 612 | $wpdb->query( "CREATE TABLE {$wpdb->usermeta}_temp LIKE $wpdb->usermeta" ); 613 | $wpdb->query( "INSERT INTO {$wpdb->usermeta}_temp SELECT * FROM $wpdb->usermeta" ); 614 | 615 | // Just truncate these fields 616 | $wpdb->query( "UPDATE {$wpdb->usermeta}_temp SET meta_value='' WHERE meta_key='description' OR meta_key='session_tokens'" ); 617 | 618 | for ( $i = 0; $i < count( $user_ids ); $i++ ) { 619 | if ( 1 < $i && 0 === $i % 1000 ) { 620 | usleep( 100 ); 621 | } 622 | 623 | $user_id = $user_ids[ $i ]; 624 | 625 | $dummy_user = $dummy_users[ $user_id % 1000 ]; 626 | 627 | $wpdb->query( "UPDATE {$wpdb->usermeta}_temp SET meta_value='{$dummy_user['first_name']}' WHERE meta_key='first_name' AND user_id='{$user_id}'" ); 628 | $wpdb->query( "UPDATE {$wpdb->usermeta}_temp SET meta_value='{$dummy_user['last_name']}' WHERE meta_key='last_name' AND user_id='{$user_id}'" ); 629 | $wpdb->query( "UPDATE {$wpdb->usermeta}_temp SET meta_value='{$dummy_user['first_name']}' WHERE meta_key='nickname' AND user_id='{$user_id}'" ); 630 | } 631 | 632 | $command = '/usr/bin/env mysqldump --no-defaults --single-transaction %s'; 633 | 634 | $command_esc_args = array( DB_NAME ); 635 | 636 | $command .= ' --tables %s'; 637 | $command_esc_args[] = $wpdb->usermeta . '_temp'; 638 | 639 | $mysql_args = [ 640 | 'host' => DB_HOST, 641 | 'pass' => DB_PASSWORD, 642 | 'user' => DB_USER, 643 | 'result-file' => $snapshot_path . 'data-usermeta.sql', 644 | ]; 645 | 646 | $escaped_command = call_user_func_array( '\WPSnapshots\Utils\esc_cmd', array_merge( array( $command ), $command_esc_args ) ); 647 | 648 | Log::instance()->write( 'Exporting usermeta...', 1 ); 649 | 650 | Utils\run_mysql_command( $escaped_command, $mysql_args ); 651 | 652 | $usermeta_sql = file_get_contents( $snapshot_path . 'data-usermeta.sql' ); 653 | 654 | Log::instance()->write( 'Appending scrubbed SQL to dump file...', 1 ); 655 | 656 | file_put_contents( $snapshot_path . 'data.sql', preg_replace( '#`' . $wpdb->users . '_temp`#', $wpdb->users, $users_sql ) . preg_replace( '#`' . $wpdb->usermeta . '_temp`#', $wpdb->usermeta, $usermeta_sql ), FILE_APPEND ); 657 | 658 | Log::instance()->write( 'Removing temporary tables...', 1 ); 659 | 660 | $wpdb->query( "DROP TABLE {$wpdb->usermeta}_temp" ); 661 | $wpdb->query( "DROP TABLE {$wpdb->users}_temp" ); 662 | 663 | Log::instance()->write( 'Removing old users and usermeta SQL...', 1 ); 664 | 665 | unlink( $snapshot_path . 'data-users.sql' ); 666 | unlink( $snapshot_path . 'data-usermeta.sql' ); 667 | } 668 | 669 | Log::instance()->write( 'Compressing database backup...', 1 ); 670 | 671 | exec( 'gzip -9 ' . Utils\escape_shell_path( $snapshot_path ) . 'data.sql ' . $verbose_pipe ); 672 | } 673 | 674 | /** 675 | * Create file back up of wp-content in .wpsnapshots/files.tar.gz 676 | */ 677 | 678 | if ( $args['contains_files'] ) { 679 | Log::instance()->write( 'Saving files...' ); 680 | 681 | $excludes = ''; 682 | 683 | if ( ! empty( $args['exclude'] ) ) { 684 | foreach ( $args['exclude'] as $exclude ) { 685 | $exclude = trim( $exclude ); 686 | 687 | if ( ! preg_match( '#^\./.*#', $exclude ) ) { 688 | $exclude = './' . $exclude; 689 | } 690 | 691 | Log::instance()->write( 'Excluding ' . $exclude, 1 ); 692 | 693 | $excludes .= ' --exclude="' . $exclude . '"'; 694 | } 695 | } 696 | 697 | Log::instance()->write( 'Compressing files...', 1 ); 698 | 699 | $v_flag = ( ! empty( Log::instance()->getVerbosity() ) ) ? 'v' : ''; 700 | 701 | $command = 'cd ' . escapeshellarg( WP_CONTENT_DIR ) . '/ && tar ' . $excludes . ' -zc' . $v_flag . 'f ' . Utils\escape_shell_path( $snapshot_path ) . 'files.tar.gz . ' . $verbose_pipe; 702 | 703 | Log::instance()->write( $command, 2 ); 704 | 705 | exec( $command ); 706 | } 707 | 708 | if ( $args['contains_db'] ) { 709 | $meta['db_size'] = filesize( $snapshot_path . 'data.sql.gz' ); 710 | } 711 | 712 | if ( $args['contains_files'] ) { 713 | $meta['files_size'] = filesize( $snapshot_path . 'files.tar.gz' ); 714 | } 715 | 716 | /** 717 | * Finally save snapshot meta to meta.json 718 | */ 719 | $meta->saveLocal(); 720 | 721 | $snapshot = new self( $id, $args['repository'], $meta ); 722 | 723 | return $snapshot; 724 | } 725 | 726 | /** 727 | * Download snapshot from remote DB. 728 | * 729 | * @param string $id Snapshot id 730 | * @param string $repository_name Name of repo 731 | * @param bool $no_files If set to true, files will not be downloaded even if available. Defaults to false. 732 | * @param bool $no_db If set to true, db will not be downloaded even if available. Defaults to false. 733 | * @return bool|Snapshot 734 | */ 735 | public static function getRemote( $id, $repository_name, $no_files = false, $no_db = false ) { 736 | if ( $no_files && $no_db ) { 737 | Log::instance()->write( 'Either files or database must be downloaded.', 0, 'error' ); 738 | 739 | return false; 740 | } 741 | 742 | $create_dir = Utils\create_snapshot_directory( $id, true ); 743 | 744 | if ( ! $create_dir ) { 745 | Log::instance()->write( 'Cannot create necessary snapshot directories.', 0, 'error' ); 746 | 747 | return false; 748 | } 749 | 750 | $repository = RepositoryManager::instance()->setup( $repository_name ); 751 | 752 | if ( ! $repository ) { 753 | Log::instance()->write( 'Could not setup repository.', 0, 'error' ); 754 | 755 | return false; 756 | } 757 | 758 | Log::instance()->write( 'Getting snapshot information...' ); 759 | 760 | $meta = Meta::getRemote( $id, $repository_name ); 761 | 762 | if ( empty( $meta ) ) { 763 | Log::instance()->write( 'Could not get snapshot from database.', 0, 'error' ); 764 | 765 | return false; 766 | } 767 | 768 | if ( empty( $meta['project'] ) ) { 769 | Log::instance()->write( 'Missing critical snapshot data.', 0, 'error' ); 770 | 771 | return false; 772 | } 773 | 774 | if ( $no_files ) { 775 | $meta['contains_files'] = false; 776 | } 777 | 778 | if ( $no_db ) { 779 | $meta['contains_db'] = false; 780 | } 781 | 782 | /** 783 | * Backwards compant. Add repository to meta before we started saving it. 784 | */ 785 | if ( empty( $meta['repository'] ) ) { 786 | $meta['repository'] = $repository_name; 787 | } 788 | 789 | $formatted_size = ''; 790 | 791 | if ( empty( $meta['files_size'] ) && empty( $meta['db_size'] ) ) { 792 | if ( $meta['contains_files'] && $meta['contains_db'] ) { 793 | $formatted_size = ' (' . Utils\format_bytes( $meta['size'] ) . ')'; 794 | } 795 | } else { 796 | $size = (int) $meta['files_size'] + (int) $meta['db_size']; 797 | 798 | $formatted_size = ' (' . Utils\format_bytes( $size ) . ')'; 799 | } 800 | 801 | $snapshot = new self( $id, $repository_name, $meta, true ); 802 | 803 | Log::instance()->write( 'Downloading snapshot' . $formatted_size . '...' ); 804 | 805 | $download = $repository->getS3()->downloadSnapshot( $snapshot ); 806 | 807 | if ( ! $download ) { 808 | Log::instance()->write( 'Failed to download snapshot.', 0, 'error' ); 809 | 810 | return false; 811 | } 812 | 813 | /** 814 | * Finally save snapshot meta to meta.json 815 | */ 816 | 817 | $save_local = $meta->saveLocal(); 818 | 819 | if ( ! $save_local ) { 820 | Log::instance()->write( 'Could not create .wpsnapshots/' . $id . '/meta.json.', 0, 'error' ); 821 | 822 | return false; 823 | } 824 | 825 | return $snapshot; 826 | } 827 | 828 | /** 829 | * Push snapshot to repository 830 | * 831 | * @return boolean 832 | */ 833 | public function push() { 834 | if ( $this->remote ) { 835 | Log::instance()->write( 'Snapshot already pushed.', 0, 'error' ); 836 | return false; 837 | } 838 | 839 | $repository = RepositoryManager::instance()->setup( $this->repository_name ); 840 | 841 | if ( ! $repository ) { 842 | Log::instance()->write( 'Could not setup repository.', 0, 'error' ); 843 | 844 | return false; 845 | } 846 | 847 | /** 848 | * Put files to S3 849 | */ 850 | Log::instance()->write( 'Uploading files (' . Utils\format_bytes( ( (int) $this->meta['files_size'] + (int) $this->meta['db_size'] ) ) . ')...' ); 851 | 852 | $s3_add = $repository->getS3()->putSnapshot( $this ); 853 | 854 | if ( ! $s3_add ) { 855 | Log::instance()->write( 'Could not upload files to S3.', 0, 'error' ); 856 | 857 | return false; 858 | } 859 | 860 | /** 861 | * Add snapshot to DB 862 | */ 863 | Log::instance()->write( 'Adding snapshot to database...' ); 864 | 865 | $inserted_snapshot = $repository->getDB()->insertSnapshot( $this ); 866 | 867 | if ( ! $inserted_snapshot ) { 868 | Log::instance()->write( 'Could not add snapshot to database.', 0, 'error' ); 869 | 870 | return false; 871 | } 872 | 873 | $this->remote = true; 874 | 875 | return true; 876 | } 877 | } 878 | -------------------------------------------------------------------------------- /src/classes/WordPressBridge.php: -------------------------------------------------------------------------------- 1 | "'" . $path . "wp-config.php'", 39 | '__DIR__' => "'" . $path . "'", 40 | ]; 41 | } else { 42 | // Must be one directory up 43 | $path_replacements = [ 44 | '__FILE__' => "'" . dirname( $path ) . "/wp-config.php'", 45 | '__DIR__' => "'" . dirname( $path ) . "'", 46 | ]; 47 | } 48 | 49 | /** 50 | * First thing we do is try to test config DB settings mixed in with user defined DB settings 51 | * before defining constants. The purpose of this is to guess the correct DB_HOST if the connection 52 | * doesn't work. 53 | */ 54 | $pre_config_constants = []; 55 | 56 | foreach ( $wp_config_code as $line ) { 57 | if ( preg_match( '#define\(.*?("|\')DB_HOST("|\').*?\).*?;#', $line ) ) { 58 | $pre_config_constants['DB_HOST'] = preg_replace( '#define\(.*?("|\')DB_HOST("|\').*?,.*?("|\')(.*?)("|\').*?\).*?;#', '$4', $line ); 59 | } elseif ( preg_match( '#define\(.*?("|\')DB_USER("|\').*?\).*?;#', $line ) ) { 60 | $pre_config_constants['DB_USER'] = preg_replace( '#define\(.*?("|\')DB_USER("|\').*?,.*?("|\')(.*?)("|\').*?\).*?;#', '$4', $line ); 61 | } elseif ( preg_match( '#define\(.*?("|\')DB_NAME("|\').*?\).*?;#', $line ) ) { 62 | $pre_config_constants['DB_NAME'] = preg_replace( '#define\(.*?("|\')DB_NAME("|\').*?,.*?("|\')(.*?)("|\').*?\).*?;#', '$4', $line ); 63 | } elseif ( preg_match( '#define\(.*?("|\')DB_PASSWORD("|\').*?\).*?;#', $line ) ) { 64 | $pre_config_constants['DB_PASSWORD'] = preg_replace( '#define\(.*?("|\')DB_PASSWORD("|\').*?,.*?("|\')(.*?)("|\').*?\).*?;#', '$4', $line ); 65 | } 66 | } 67 | 68 | foreach ( $extra_config_constants as $config_constant => $config_constant_value ) { 69 | $pre_config_constants[ $config_constant ] = $config_constant_value; 70 | } 71 | 72 | if ( ! empty( $pre_config_constants['DB_HOST'] ) && ! empty( $pre_config_constants['DB_NAME'] ) && ! empty( $pre_config_constants['DB_USER'] ) && ! empty( $pre_config_constants['DB_PASSWORD'] ) ) { 73 | $connection = Utils\test_mysql_connection( $pre_config_constants['DB_HOST'], $pre_config_constants['DB_NAME'], $pre_config_constants['DB_USER'], $pre_config_constants['DB_PASSWORD'] ); 74 | 75 | if ( true !== $connection ) { 76 | $connection = Utils\test_mysql_connection( '127.0.0.1', $pre_config_constants['DB_NAME'], $pre_config_constants['DB_USER'], $pre_config_constants['DB_PASSWORD'] ); 77 | 78 | if ( true === $connection ) { 79 | $extra_config_constants['DB_HOST'] = '127.0.0.1'; 80 | } 81 | } 82 | } 83 | 84 | foreach ( $wp_config_code as $line ) { 85 | if ( preg_match( '/^\s*require.+wp-settings\.php/', $line ) ) { 86 | continue; 87 | } 88 | 89 | /** 90 | * Don't execute override constants 91 | */ 92 | foreach ( $extra_config_constants as $config_constant => $config_constant_value ) { 93 | if ( preg_match( '#define\(.*?("|\')' . $config_constant . '("|\').*?\).*?;#', $line ) ) { 94 | continue 2; 95 | } 96 | } 97 | 98 | /** 99 | * Swap path related constants so we can run WP as a composer dependancy 100 | */ 101 | $line = str_replace( array_keys( $path_replacements ), array_values( $path_replacements ), $line ); 102 | 103 | $lines_to_run[] = $line; 104 | } 105 | 106 | $source = implode( "\n", $lines_to_run ); 107 | 108 | define( 'ABSPATH', $path ); 109 | 110 | /** 111 | * Set constant for instances in theme or plugin code that may prevent wpsnapshots from executing properly. 112 | */ 113 | define( 'WPSNAPSHOTS', true ); 114 | 115 | /** 116 | * Define some server variables we might need 117 | */ 118 | $_SERVER['REMOTE_ADDR'] = '1.1.1.1'; 119 | 120 | /** 121 | * Add in override constants 122 | */ 123 | foreach ( $extra_config_constants as $config_constant => $config_constant_value ) { 124 | define( $config_constant, $config_constant_value ); 125 | } 126 | 127 | eval( preg_replace( '|^\s*\<\?php\s*|', '', $source ) ); 128 | 129 | if ( defined( 'DOMAIN_CURRENT_SITE' ) ) { 130 | $url = DOMAIN_CURRENT_SITE; 131 | if ( defined( 'PATH_CURRENT_SITE' ) ) { 132 | $url .= PATH_CURRENT_SITE; 133 | } 134 | 135 | $url_parts = parse_url( $url ); 136 | 137 | if ( ! isset( $url_parts['scheme'] ) ) { 138 | $url_parts = parse_url( 'http://' . $url ); 139 | } 140 | 141 | if ( isset( $url_parts['host'] ) ) { 142 | if ( isset( $url_parts['scheme'] ) && 'https' === strtolower( $url_parts['scheme'] ) ) { 143 | $_SERVER['HTTPS'] = 'on'; 144 | } 145 | 146 | $_SERVER['HTTP_HOST'] = $url_parts['host']; 147 | if ( isset( $url_parts['port'] ) ) { 148 | $_SERVER['HTTP_HOST'] .= ':' . $url_parts['port']; 149 | } 150 | 151 | $_SERVER['SERVER_NAME'] = $url_parts['host']; 152 | } 153 | 154 | $_SERVER['REQUEST_URI'] = $url_parts['path'] . ( isset( $url_parts['query'] ) ? '?' . $url_parts['query'] : '' ); 155 | $_SERVER['SERVER_PORT'] = ( isset( $url_parts['port'] ) ) ? $url_parts['port'] : 80; 156 | $_SERVER['QUERY_STRING'] = ( isset( $url_parts['query'] ) ) ? $url_parts['query'] : ''; 157 | } 158 | 159 | Log::instance()->write( 'Testing MySQL connection.', 1 ); 160 | 161 | // Test DB connect 162 | $connection = Utils\test_mysql_connection( DB_HOST, DB_NAME, DB_USER, DB_PASSWORD ); 163 | 164 | if ( true !== $connection ) { 165 | if ( false !== strpos( $connection, 'php_network_getaddresses' ) ) { 166 | Log::instance()->write( "Couldn't connect to MySQL host.", 0, 'error' ); 167 | } else { 168 | Log::instance()->write( 'Could not connect to MySQL. Is your connection info correct?', 0, 'error' ); 169 | 170 | Log::instance()->write( 'MySQL error: ' . $connection, 1, 'error' ); 171 | } 172 | 173 | Log::instance()->write( 'MySQL connection info:', 1 ); 174 | Log::instance()->write( 'DB_HOST: ' . DB_HOST, 1 ); 175 | Log::instance()->write( 'DB_NAME: ' . DB_NAME, 1 ); 176 | Log::instance()->write( 'DB_USER: ' . DB_USER, 1 ); 177 | Log::instance()->write( 'DB_PASSWORD: ' . DB_PASSWORD, 1 ); 178 | 179 | return false; 180 | } 181 | 182 | // We can require settings after we fake $_SERVER keys 183 | require_once ABSPATH . 'wp-settings.php'; 184 | 185 | return true; 186 | } 187 | 188 | /** 189 | * Return singleton instance of class 190 | * 191 | * @return object 192 | */ 193 | public static function instance() { 194 | static $instance; 195 | 196 | if ( empty( $instance ) ) { 197 | $instance = new self(); 198 | } 199 | 200 | return $instance; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/utils.php: -------------------------------------------------------------------------------- 1 | real_connect( $host, $user, $password, $database ) ) ? mysqli_connect_error() : true; 25 | } 26 | 27 | /** 28 | * Get all constants in wp-config.php 29 | * 30 | * @param string $wp_config_path Path to wp-config.php 31 | * @return array 32 | */ 33 | function get_wp_config_constants( $wp_config_path ) { 34 | $wp_config_code = explode( "\n", file_get_contents( $wp_config_path ) ); 35 | $constants = []; 36 | 37 | foreach ( $wp_config_code as $line ) { 38 | if ( preg_match( '#define\(.*?("|\')(.*?)("|\').*?\).*?;#', $line ) ) { 39 | $constant_name = preg_replace( '#^.*?define\(.*?("|\')(.*?)("|\').*$#', '$2', $line ); 40 | 41 | $constants[ $constant_name ] = trim( preg_replace( '#^.*?define\(.*?("|\').*?("|\').*?,(.*)\).*?;.*$#', '$3', $line ), ' ' ); 42 | 43 | if ( preg_match( '#^".*"$#', $constants[ $constant_name ] ) ) { 44 | $constants[ $constant_name ] = preg_replace( '#^"(.*)"$#', '$1', $constants[ $constant_name ] ); 45 | } elseif ( preg_match( "#^'.*'$#", $constants[ $constant_name ] ) ) { 46 | $constants[ $constant_name ] = preg_replace( "#^'(.*)'$#", '$1', $constants[ $constant_name ] ); 47 | } 48 | 49 | // Appropriately cast variables 50 | if ( is_numeric( $constants[ $constant_name ] ) ) { 51 | if ( false !== strpos( $constants[ $constant_name ], '.' ) ) { 52 | $constants[ $constant_name ] = (double) $constants[ $constant_name ]; 53 | } else { 54 | $constants[ $constant_name ] = (int) $constants[ $constant_name ]; 55 | } 56 | } elseif ( 'false' === strtolower( $constants[ $constant_name ] ) ) { 57 | $constants[ $constant_name ] = false; 58 | } elseif ( 'true' === strtolower( $constants[ $constant_name ] ) ) { 59 | $constants[ $constant_name ] = true; 60 | } 61 | } 62 | } 63 | 64 | return $constants; 65 | } 66 | 67 | /** 68 | * Write constants to wp-config.php ensuring the same constants don't get written twice. 69 | * 70 | * @param array $constants Constants array 71 | * @param string $wp_config_path Path to wp-config.php 72 | */ 73 | function write_constants_to_wp_config( $constants, $wp_config_path ) { 74 | $wp_config_code = explode( "\n", file_get_contents( $wp_config_path ) ); 75 | $new_wp_config_code = []; 76 | 77 | foreach ( $wp_config_code as $line ) { 78 | // We'll add this back later 79 | if ( preg_match( '#^<\?php.*#i', $line ) ) { 80 | continue; 81 | } 82 | 83 | // Don't readd lines that contain constants we are defining 84 | if ( preg_match( '#define\(.*?("|\')(.*?)("|\').*?\).*?;#', $line ) ) { 85 | $constant_name = preg_replace( '#^.*?define\(.*?("|\')(.*?)("|\').*$#', '$2', $line ); 86 | 87 | if ( ! empty( $constants[ $constant_name ] ) ) { 88 | continue; 89 | } 90 | } 91 | 92 | $new_wp_config_code[] = $line; 93 | } 94 | 95 | foreach ( $constants as $constant_name => $constant_value ) { 96 | if ( false === $constant_value ) { 97 | $constant_value = 'false'; 98 | } elseif ( true === $constant_value ) { 99 | $constant_value = 'true'; 100 | } elseif ( is_string( $constant_value ) ) { 101 | $constant_value = addcslashes( $constant_value, "'" ); 102 | 103 | $constant_value = "'$constant_value'"; 104 | } 105 | 106 | array_unshift( $new_wp_config_code, 'define( "' . $constant_name . '", ' . $constant_value . ' ); // Auto added.' ); 107 | } 108 | 109 | array_unshift( $new_wp_config_code, ' $config_constant_value ) { 235 | if ( preg_match( '#define\(.*?("|\')' . $config_constant . '("|\').*?\).*?;#', $line ) ) { 236 | continue 2; 237 | } 238 | } 239 | 240 | $new_file[] = $line; 241 | } 242 | 243 | foreach ( $constants as $config_constant => $config_constant_value ) { 244 | if ( ! is_bool( $config_constant_value ) && ! is_int( $config_constant_value ) ) { 245 | $config_constant_value = "'" . addslashes( $config_constant_value ) . "'"; 246 | } 247 | 248 | $new_file[] = "define( '" . addslashes( $config_constant ) . "', $config_constant_value );"; 249 | } 250 | 251 | $new_file[] = "require_once(ABSPATH . 'wp-settings.php');"; 252 | 253 | file_put_contents( $path, implode( "\n", $new_file ) ); 254 | } 255 | 256 | /** 257 | * Get download url for WP 258 | * 259 | * @param string $version WP version 260 | * @param string $locale Language locale 261 | * @return string|bool 262 | */ 263 | function get_download_url( $version = 'latest', $locale = 'en_US' ) { 264 | if ( ! $version ) { 265 | return false; 266 | } 267 | 268 | if ( 'nightly' === $version ) { 269 | return 'https://wordpress.org/nightly-builds/wordpress-latest.zip'; 270 | } 271 | 272 | if ( 'latest' === $version ) { 273 | $headers = [ 'Accept' => 'application/json' ]; 274 | 275 | try { 276 | $request = Requests::get( 'https://api.wordpress.org/core/version-check/1.6/?locale=' . $locale, $headers ); 277 | } catch ( \Exception $e ) { 278 | return false; 279 | } 280 | 281 | if ( 200 !== (int) $request->status_code ) { 282 | return false; 283 | } 284 | 285 | $request_body = unserialize( $request->body ); 286 | 287 | if ( empty( $request_body['offers'] ) || empty( $request_body['offers'][0] ) || empty( $request_body['offers'][0]['download'] ) ) { 288 | return false; 289 | } 290 | 291 | return str_replace( '.zip', '.tar.gz', $request_body['offers'][0]['download'] ); 292 | } 293 | 294 | if ( 'en_US' === $locale || ! $locale ) { 295 | $url = 'https://wordpress.org/wordpress-' . $version . '.tar.gz'; 296 | 297 | return $url; 298 | } else { 299 | $url = sprintf( 300 | 'https://%s.wordpress.org/wordpress-%s-%s.tar.gz', 301 | substr( $locale, 0, 2 ), 302 | $version, 303 | $locale 304 | ); 305 | 306 | return $url; 307 | } 308 | } 309 | 310 | /** 311 | * Is WordPress in the directory? 312 | * 313 | * @param string $path Path to WordPress directory 314 | * @return boolean 315 | */ 316 | function is_wp_present( $path ) { 317 | return ( file_exists( trailingslash( $path ) . 'wp-settings.php' ) ); 318 | } 319 | 320 | /** 321 | * Find wp-config.php 322 | * 323 | * @param string $path Path to search for wp-config.php 324 | * @return string 325 | */ 326 | function locate_wp_config( $path ) { 327 | $path = trailingslash( $path ); 328 | 329 | if ( file_exists( $path . 'wp-config.php' ) ) { 330 | $path = $path . 'wp-config.php'; 331 | } elseif ( file_exists( $path . '../wp-config.php' ) ) { 332 | $path = $path . '../wp-config.php'; 333 | } else { 334 | return false; 335 | } 336 | 337 | return realpath( $path ); 338 | } 339 | 340 | /** 341 | * Create snapshots cache. Providing an id creates the subdirectory as well. 342 | * 343 | * @param string $id Optional ID. Setting this will create the snapshot directory. 344 | * @param bool $hard Overwrite an existing snapshot 345 | * @return bool 346 | */ 347 | function create_snapshot_directory( $id = null, $hard = false ) { 348 | if ( ! file_exists( get_snapshot_directory() ) ) { 349 | $dir_result = @mkdir( get_snapshot_directory(), 0755 ); 350 | 351 | if ( ! $dir_result ) { 352 | return false; 353 | } 354 | } 355 | 356 | if ( ! is_writable( get_snapshot_directory() ) ) { 357 | return false; 358 | } 359 | 360 | if ( ! empty( $id ) ) { 361 | if ( $hard && file_exists( get_snapshot_directory() . $id . '/' ) ) { 362 | array_map( 'unlink', glob( get_snapshot_directory() . $id . '/*.*' ) ); 363 | $rm_result = rmdir( get_snapshot_directory() . $id . '/' ); 364 | 365 | if ( ! $rm_result ) { 366 | return false; 367 | } 368 | } 369 | 370 | if ( ! file_exists( get_snapshot_directory() . $id . '/' ) ) { 371 | $dir_result = mkdir( get_snapshot_directory() . $id . '/', 0755 ); 372 | 373 | if ( ! $dir_result ) { 374 | return false; 375 | } 376 | } 377 | 378 | if ( ! is_writable( get_snapshot_directory() . $id . '/' ) ) { 379 | return false; 380 | } 381 | } 382 | 383 | return true; 384 | } 385 | 386 | /** 387 | * Get path to snapshot cache directory with trailing slash. If env variable WPSNAPSHOTS_DIR is 388 | * set, then use that. 389 | * 390 | * @return string 391 | */ 392 | function get_snapshot_directory() { 393 | $env_dir = getenv( 'WPSNAPSHOTS_DIR' ); 394 | 395 | return ( ! empty( $env_dir ) ) ? rtrim( $env_dir, '/' ) . '/' : rtrim( $_SERVER['HOME'], '/' ) . '/.wpsnapshots/'; 396 | } 397 | 398 | /** 399 | * Generate unique snapshot ID 400 | * 401 | * @return string 402 | */ 403 | function generate_snapshot_id() { 404 | return md5( time() . '' . rand() ); 405 | } 406 | 407 | /** 408 | * Check if snapshot is in cache 409 | * 410 | * @param string $id Snapshot id 411 | * @return boolean 412 | */ 413 | function is_snapshot_cached( $id ) { 414 | if ( ! file_exists( get_snapshot_directory() . $id . '/data.sql.gz' ) || ! file_exists( get_snapshot_directory() . $id . '/files.tar.gz' ) ) { 415 | return false; 416 | } 417 | 418 | return true; 419 | } 420 | 421 | /** 422 | * Run MySQL command via proc given associative command line args 423 | * 424 | * @param string $cmd MySQL command 425 | * @param array $assoc_args Args to pass to MySQL 426 | * @param string $append String to append to command 427 | * @param bool $exit_on_error Whether to exit on error or not. 428 | * @return string 429 | */ 430 | function run_mysql_command( $cmd, $assoc_args, $append = '', $exit_on_error = true ) { 431 | check_proc_available( 'run_mysql_command' ); 432 | 433 | if ( isset( $assoc_args['host'] ) ) { 434 | $assoc_args = array_merge( $assoc_args, mysql_host_to_cli_args( $assoc_args['host'] ) ); 435 | } 436 | 437 | $pass = $assoc_args['pass']; 438 | unset( $assoc_args['pass'] ); 439 | 440 | $old_pass = getenv( 'MYSQL_PWD' ); 441 | putenv( 'MYSQL_PWD=' . $pass ); 442 | 443 | $final_cmd = force_env_on_nix_systems( $cmd ) . assoc_args_to_str( $assoc_args ) . $append; 444 | 445 | $proc = proc_open( $final_cmd, [ STDIN, STDOUT, STDERR ], $pipes ); 446 | 447 | if ( $exit_on_error && ! $proc ) { 448 | exit( 1 ); 449 | } 450 | 451 | $r = proc_close( $proc ); 452 | 453 | putenv( 'MYSQL_PWD=' . $old_pass ); 454 | 455 | if ( $exit_on_error ) { 456 | if ( $r ) { 457 | exit( $r ); 458 | } 459 | } else { 460 | return $r; 461 | } 462 | } 463 | 464 | /** 465 | * Returns tables 466 | * 467 | * @param bool $wp Whether to only return WP tables 468 | * @return array 469 | */ 470 | function get_tables( $wp = true ) { 471 | global $wpdb; 472 | 473 | $tables = []; 474 | 475 | $results = $wpdb->get_results( 'SHOW TABLES', ARRAY_A ); 476 | 477 | foreach ( $results as $table_info ) { 478 | $table_info = array_values( $table_info ); 479 | $table = $table_info[0]; 480 | 481 | if ( $wp ) { 482 | if ( 0 === strpos( $table, $wpdb->base_prefix ) ) { 483 | $tables[] = $table; 484 | } 485 | } else { 486 | $tables[] = $table; 487 | } 488 | } 489 | 490 | return $tables; 491 | } 492 | 493 | /** 494 | * Translate mysql host to cli args 495 | * 496 | * @param string $raw_host Host string 497 | * @return array 498 | */ 499 | function mysql_host_to_cli_args( $raw_host ) { 500 | $assoc_args = array(); 501 | $host_parts = explode( ':', $raw_host ); 502 | 503 | if ( count( $host_parts ) == 2 ) { 504 | list( $assoc_args['host'], $extra ) = $host_parts; 505 | $extra = trim( $extra ); 506 | 507 | if ( is_numeric( $extra ) ) { 508 | $assoc_args['port'] = intval( $extra ); 509 | $assoc_args['protocol'] = 'tcp'; 510 | } elseif ( '' !== $extra ) { 511 | $assoc_args['socket'] = $extra; 512 | } 513 | } else { 514 | $assoc_args['host'] = $raw_host; 515 | } 516 | 517 | return $assoc_args; 518 | } 519 | 520 | /** 521 | * Shell escape command as an array 522 | * 523 | * @param array $cmd Shell command 524 | * @return array 525 | */ 526 | function esc_cmd( $cmd ) { 527 | if ( func_num_args() < 2 ) { 528 | trigger_error( 'esc_cmd() requires at least two arguments.', E_USER_WARNING ); 529 | } 530 | 531 | $args = func_get_args(); 532 | $cmd = array_shift( $args ); 533 | 534 | return vsprintf( $cmd, array_map( 'escapeshellarg', $args ) ); 535 | } 536 | 537 | /** 538 | * Make sure env path is used on *nix 539 | * 540 | * @param string $command Command string. 541 | * @return string 542 | */ 543 | function force_env_on_nix_systems( $command ) { 544 | $env_prefix = '/usr/bin/env '; 545 | $env_prefix_len = strlen( $env_prefix ); 546 | 547 | if ( is_windows() ) { 548 | if ( 0 === strncmp( $command, $env_prefix, $env_prefix_len ) ) { 549 | $command = substr( $command, $env_prefix_len ); 550 | } 551 | } else { 552 | if ( 0 !== strncmp( $command, $env_prefix, $env_prefix_len ) ) { 553 | $command = $env_prefix . $command; 554 | } 555 | } 556 | 557 | return $command; 558 | } 559 | 560 | /** 561 | * Determine if we are on windows 562 | * 563 | * @return bool 564 | */ 565 | function is_windows() { 566 | return strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN'; 567 | } 568 | 569 | /** 570 | * Convert assoc array to string to command 571 | * 572 | * @param array $assoc_args Associative args 573 | * @return string 574 | */ 575 | function assoc_args_to_str( $assoc_args ) { 576 | $str = ''; 577 | 578 | foreach ( $assoc_args as $key => $value ) { 579 | if ( true === $value ) { 580 | $str .= " --$key"; 581 | } elseif ( is_array( $value ) ) { 582 | foreach ( $value as $_ => $v ) { 583 | $str .= assoc_args_to_str( array( $key => $v ) ); 584 | } 585 | } else { 586 | $str .= " --$key=" . escapeshellarg( $value ); 587 | } 588 | } 589 | 590 | return $str; 591 | } 592 | 593 | /** 594 | * Escape sql name e.g. table name 595 | * 596 | * @param string $name Name to escape 597 | * @return string 598 | */ 599 | function esc_sql_name( $name ) { 600 | return preg_replace( '#["\'`]#', '', $name ); 601 | } 602 | 603 | /** 604 | * Format bytes to pretty file size 605 | * 606 | * @param int $size Number of bytes 607 | * @param int $precision Decimal precision 608 | * @return string 609 | */ 610 | function format_bytes( $size, $precision = 2 ) { 611 | $base = log( $size, 1024 ); 612 | $suffixes = [ '', 'KB', 'MB', 'GB', 'TB' ]; 613 | 614 | return round( pow( 1024, $base - floor( $base ) ), $precision ) . ' ' . $suffixes[ floor( $base ) ]; 615 | } 616 | 617 | /** 618 | * Determine if proc is available 619 | * 620 | * @return bool 621 | */ 622 | function check_proc_available() { 623 | if ( ! function_exists( 'proc_open' ) || ! function_exists( 'proc_close' ) ) { 624 | return false; 625 | } 626 | 627 | return true; 628 | } 629 | 630 | /** 631 | * Get array of dummy user data to use for scrubbing 632 | * 633 | * @return array 634 | */ 635 | function get_dummy_users() { 636 | static $users = []; 637 | 638 | if ( empty( $users ) ) { 639 | $file = fopen( __DIR__ . '/data/users.csv', 'r' ); 640 | 641 | while ( false !== ( $line = fgetcsv( $file ) ) ) { 642 | 643 | $user = [ 644 | 'username' => $line[0], 645 | 'first_name' => $line[1], 646 | 'last_name' => $line[2], 647 | 'email' => $line[3], 648 | ]; 649 | 650 | $users[] = $user; 651 | } 652 | 653 | fclose( $file ); 654 | } 655 | 656 | return $users; 657 | } 658 | --------------------------------------------------------------------------------