├── .circleci └── config.yml ├── .github └── FUNDING.yml ├── .gitignore ├── README.md ├── composer.json ├── composer.lock ├── css └── admin.css ├── includes └── libraries │ └── Phpuri.php ├── js ├── admin-generate.js └── admin-settings.js ├── phpstan.neon ├── readme.txt ├── simplerstatic.php ├── src ├── Archive_Creation_Job.php ├── Cancel_Task.php ├── Create_Zip_Archive_Task.php ├── Diagnostic.php ├── Fetch_Urls_Task.php ├── Model.php ├── Options.php ├── Page.php ├── Plugin.php ├── Query.php ├── Setup_Task.php ├── SimplerStaticException.php ├── Sql_Permissions.php ├── Task.php ├── Transfer_Files_Locally_Task.php ├── Upgrade_Handler.php ├── Url_Extractor.php ├── Url_Fetcher.php ├── Util.php ├── View.php └── Wrapup_Task.php ├── tests └── phpstan │ ├── bootstrap.php │ └── wp-cli-stubs-2.2.0.php ├── tools ├── build_release.sh └── phpcs.xml ├── uninstall.php └── views ├── _activity_log.php ├── _export_log.php ├── _pagination.php ├── diagnostics.php ├── generate.php ├── layouts └── admin.php ├── redirect.php └── settings.php /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | test: 4 | docker: 5 | - image: circleci/php:7.3.9-stretch 6 | steps: 7 | - run: sudo rm /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 8 | - checkout 9 | - run: composer validate --strict 10 | - run: composer install --no-interaction --no-suggest 11 | - run: composer test 12 | # - run: vendor/bin/parallel-lint --no-progress bootstrap.php src/ 13 | # - run: vendor/bin/phpcs --standard=PSR12NeutronRuleset --exclude=PEAR.Commenting.ClassComment,PEAR.Commenting.FileComment,Generic.Files.LineLength bootstrap.php src/ 14 | # - run: vendor/bin/phpstan analyze --no-progress 15 | workflows: 16 | version: 2 17 | workflow: 18 | jobs: 19 | - test 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: leonstafford 4 | custom: ['https://paypal.me/ljsdotdev', 'https://ljs.dev', 'https://donorbox.org/leonstafford'] 5 | patreon: leonstafford 6 | ko_fi: leonstafford 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /vendor_prefixed/ 3 | coverage/ 4 | 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simpler Static 2 | 3 | A simple WordPress static site generator 4 | 5 | Official homepage/docs: [simplerstatic.com](https://simplerstatic.com) 6 | 7 | [![CircleCI](https://circleci.com/gh/leonstafford/simplerstatic.svg?style=svg)](https://circleci.com/gh/leonstafford/simplerstatic) 8 | 9 | This is an updated fork of the long dormant [Simply Static](https://wordpress.org/plugins/simply-static). 10 | 11 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wp2static/simplerstatic", 3 | "description": "A simple WordPress static site generator", 4 | "homepage": "https://simplerstatic.com", 5 | "license": "UNLICENSE", 6 | "authors": [ 7 | { 8 | "name": "Leon Stafford", 9 | "email": "me@ljs.dev", 10 | "homepage": "https://ljs.dev" 11 | } 12 | ], 13 | "type": "wordpress-plugin", 14 | "support": { 15 | "issues": "https://github.com/WP2Static/simplerstatic/issues", 16 | "source": "https://github.com/WP2Static/simplerstatic" 17 | }, 18 | "require": { 19 | "php": ">=7.3", 20 | "simplehtmldom/simplehtmldom": "^2.0-RC2", 21 | "a5hleyrich/wp-background-processing": "^1.0.1" 22 | }, 23 | "require-dev": { 24 | "phpstan/phpstan": "*", 25 | "thecodingmachine/phpstan-strict-rules": "*", 26 | "szepeviktor/phpstan-wordpress": "*", 27 | "squizlabs/php_codesniffer": "*", 28 | "phpunit/phpunit": "*", 29 | "dealerdirect/phpcodesniffer-composer-installer": "*", 30 | "wp-coding-standards/wpcs": "*", 31 | "php-parallel-lint/php-parallel-lint": "*", 32 | "10up/wp_mock": "^0.4.2" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "SimplerStatic\\": "src/" 37 | } 38 | }, 39 | "config": { 40 | "platform": { 41 | "php": "7.3" 42 | }, 43 | "preferred-install": { 44 | "*": "dist" 45 | }, 46 | "classmap-authoritative": true 47 | }, 48 | "scripts": { 49 | "phpstan": "php -d memory_limit=-1 ./vendor/bin/phpstan analyse", 50 | "phpcs": "vendor/bin/phpcs --standard=./tools/phpcs.xml --ignore=*.css,*/tests/*,*/admin/*,**/coverage/*,*.js,*/vendor/*,*/views/*.php ./", 51 | "phpcbf": "vendor/bin/phpcbf --standard=./tools/phpcs.xml --ignore=*.css,*/js/*,*/tests/*,*/admin/*,*/coverage/*,*.js,*/vendor/*,*/views/*.php ./", 52 | "phpunit": "vendor/bin/phpunit ./tests/unit/", 53 | "coverage": "vendor/bin/phpunit tests/unit --coverage-html coverage --whitelist src/", 54 | "lint": "vendor/bin/parallel-lint --exclude vendor .", 55 | "test": [ 56 | "@lint", 57 | "@phpcs", 58 | "@phpstan" 59 | ], 60 | "build": "/bin/sh tools/build_release.sh" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /css/admin.css: -------------------------------------------------------------------------------- 1 | #sistContainer { 2 | /*margin-right: 310px;*/ 3 | } 4 | 5 | #sistContainer #sistContent { 6 | width: 100%; 7 | float: left; 8 | } 9 | 10 | 11 | /* Generate page */ 12 | 13 | #sistContainer #activityLog { 14 | border: 1px solid #000; 15 | background-color: #EFF; 16 | color: #000; 17 | display: block; 18 | height: 100px; 19 | overflow: auto; 20 | padding: 5px; 21 | } 22 | 23 | #sistContainer #activityLog .error-state { 24 | color: #dc143c; 25 | } 26 | 27 | #sistContainer .spinner { 28 | float: none; 29 | } 30 | 31 | #sistContainer .actions .spinner { 32 | margin-top: 12px; 33 | } 34 | 35 | /* Adding this to ensure table fits within parent div */ 36 | #sistContainer #exportLog table { 37 | word-break: break-all; 38 | } 39 | 40 | #sistContainer #exportLog td.status-code { 41 | min-width: 35px; 42 | } 43 | 44 | #sistContainer #exportLog td.status-code.unprocessable { 45 | font-weight: bold; 46 | color: #000; 47 | } 48 | 49 | #sistContainer .hide { 50 | display: none; 51 | } 52 | 53 | #sistContainer .button.button-destroy { 54 | color: #fff; 55 | background-color: #c9302c; 56 | border-color: #ac2925; 57 | } 58 | 59 | #sistContainer .button.button-destroy:hover { 60 | background-color: #d9534f; 61 | border-color: #d43f3a; 62 | } 63 | 64 | #sistContainer .tablenav .page-numbers { 65 | padding: 6px 4px; 66 | font-size: 13px; 67 | } 68 | 69 | #sistContainer .tablenav .page-numbers.prev, #sistContainer .tablenav .page-numbers.next { 70 | padding: 3px 4px 6px; 71 | font-size: 16px; 72 | } 73 | 74 | #sistContainer .tablenav .page-numbers.current { 75 | display: inline-block; 76 | min-width: 17px; 77 | border: 1px solid #ccc; 78 | padding: 6px 4px; 79 | background: #e5e5e5; 80 | font-size: 13px; 81 | line-height: 1; 82 | font-weight: 400; 83 | text-align: center; 84 | } 85 | 86 | #sistContainer .tablenav .http-status { 87 | margin-top: 3px; 88 | line-height: 1.4em; 89 | } 90 | 91 | /* Settings page */ 92 | 93 | #sistContainer .tab-pane, tr.delivery-method { 94 | display: none; 95 | } 96 | 97 | #sistContainer .tab-pane.active, tr.delivery-method.active { 98 | display: table-row; 99 | } 100 | 101 | #sistContainer select:disabled, select.disabled { 102 | border-color: rgba(222,222,222,.75); 103 | background: rgba(255,255,255,.5); 104 | color: rgba(51,51,51,.5); 105 | } 106 | 107 | #sistContainer .help-block { 108 | display: block; 109 | margin-top: 5px; 110 | margin-bottom: 10px; 111 | color: #737373; 112 | } 113 | 114 | #sistContainer .scheme-entry { 115 | height: 28px; 116 | margin-right: 0; 117 | vertical-align: bottom; 118 | border-right: 0; 119 | } 120 | 121 | #sistContainer .host-entry { 122 | height: 28px; 123 | width: 300px; 124 | margin-left: 0; 125 | padding: 2px 5px; 126 | vertical-align: bottom; 127 | } 128 | 129 | #sistContainer .url-dest-option { 130 | border: 1px solid #f1f1f1; 131 | display: table; 132 | cursor: pointer; 133 | width: 100%; 134 | } 135 | 136 | #sistContainer .url-dest-option:hover { 137 | background-color: #ececec; 138 | border: 1px solid #ddd; 139 | } 140 | 141 | #sistContainer .url-dest-option.active { 142 | background-color: #e5e5e5; 143 | border: 1px solid #ccc; 144 | } 145 | 146 | #sistContainer .url-dest-option > span { 147 | display: table-cell; 148 | vertical-align: middle; 149 | padding: 15px 9px; 150 | } 151 | 152 | #sistContainer .url-dest-option > span:first-child { 153 | width: 10px; 154 | } 155 | 156 | #sistContainer #excludableUrlRowTemplate { 157 | display: none; 158 | } 159 | 160 | #sistContainer .excludable-url-row input[type='checkbox'], 161 | #sistContainer .excludable-url-row .button { 162 | margin-left: 10px; 163 | } 164 | 165 | /* Diagnostics page */ 166 | 167 | #sistContainer #diagnosticsPage table.striped { 168 | margin-bottom: 20px; 169 | } 170 | 171 | #sistContainer #diagnosticsPage td.label { 172 | width: 75%; 173 | } 174 | 175 | #sistContainer #diagnosticsPage td.test.success, #sistContainer #diagnosticsPage td.enabled { 176 | font-weight: bold; 177 | width: 25%; 178 | color: #008000; 179 | } 180 | 181 | #sistContainer #diagnosticsPage td.test.success::before { 182 | content: "\2713 "; 183 | } 184 | 185 | #sistContainer #diagnosticsPage td.test.error { 186 | font-weight: bold; 187 | width: 25%; 188 | color: #dc143c; 189 | } 190 | 191 | #sistContainer #diagnosticsPage td.disabled { 192 | width: 25%; 193 | } 194 | 195 | #sistContainer #diagnosticsPage td.test.error::before { 196 | content: "\2717 "; 197 | } 198 | 199 | -------------------------------------------------------------------------------- /includes/libraries/Phpuri.php: -------------------------------------------------------------------------------- 1 | 7 | * echo phpUri::parse('https://www.google.com/')->join('foo'); 8 | * //==> https://www.google.com/foo 9 | * 10 | * 11 | * Licensed under The MIT License 12 | * Redistributions of files must retain the above copyright notice. 13 | * 14 | * @author P Guardiario 15 | * @version 1.0 16 | */ 17 | 18 | class PhpUri { 19 | 20 | /** 21 | * http(s):// 22 | * 23 | * @var string 24 | */ 25 | public $scheme; 26 | 27 | /** 28 | * www.example.com 29 | * 30 | * @var string 31 | */ 32 | public $authority; 33 | 34 | /** 35 | * /search 36 | * 37 | * @var string 38 | */ 39 | public $path; 40 | 41 | /** 42 | * ?q=foo 43 | * 44 | * @var string 45 | */ 46 | public $query; 47 | 48 | /** 49 | * #bar 50 | * 51 | * @var string 52 | */ 53 | public $fragment; 54 | 55 | private function __construct( $string ) { 56 | preg_match_all( 57 | '/^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/', 58 | $string, 59 | $m 60 | ); 61 | $this->scheme = $m[2][0]; 62 | $this->authority = $m[4][0]; 63 | 64 | /** 65 | * CHANGE: 66 | * 67 | * @author Dominik Habichtsberg 68 | * @since 24 Mai 2015 10:02 Uhr 69 | * 70 | * Former code: $this->path = ( empty( $m[ 5 ][ 0 ] ) ) ? '/' : $m[ 5 ][ 0 ]; 71 | * No tests failed, when the path is empty. 72 | * With the former code, the relative urls //g and #s failed 73 | */ 74 | $this->path = $m[5][0]; 75 | $this->query = $m[7][0]; 76 | $this->fragment = $m[9][0]; 77 | } 78 | 79 | private function to_str() { 80 | $ret = ''; 81 | if ( ! empty( $this->scheme ) ) { 82 | $ret .= "{$this->scheme}:"; 83 | } 84 | 85 | if ( ! empty( $this->authority ) ) { 86 | $ret .= "//{$this->authority}"; 87 | } 88 | 89 | $ret .= $this->normalize_path( $this->path ); 90 | 91 | if ( ! empty( $this->query ) ) { 92 | $ret .= "?{$this->query}"; 93 | } 94 | 95 | if ( ! empty( $this->fragment ) ) { 96 | $ret .= "#{$this->fragment}"; 97 | } 98 | 99 | return $ret; 100 | } 101 | 102 | private function normalize_path( $path ) { 103 | if ( empty( $path ) ) { 104 | return ''; 105 | } 106 | 107 | $normalized_path = $path; 108 | $normalized_path = preg_replace( '`//+`', '/', $normalized_path, -1, $c0 ); 109 | $normalized_path = preg_replace( '`^/\\.\\.?/`', '/', $normalized_path, -1, $c1 ); 110 | $normalized_path = preg_replace( '`/\\.(/|$)`', '/', $normalized_path, -1, $c2 ); 111 | 112 | /** 113 | * CHANGE: 114 | * 115 | * @author Dominik Habichtsberg 116 | * @since 24 Mai 2015 10:05 Uhr 117 | * changed limit form -1 to 1, because climbing up the directory-tree failed 118 | */ 119 | $normalized_path = preg_replace( '`/[^/]*?/\\.\\.(/|$)`', '/', $normalized_path, 1, $c3 ); 120 | $num_matches = $c0 + $c1 + $c2 + $c3; 121 | 122 | return ( $num_matches > 0 ) ? $this->normalize_path( $normalized_path ) : $normalized_path; 123 | } 124 | 125 | /** 126 | * Parse an url string 127 | * 128 | * @param string $url the url to parse 129 | * 130 | * @return phpUri 131 | */ 132 | public static function parse( $url ) { 133 | $uri = new PhpUri( $url ); 134 | 135 | /** 136 | * CHANGE: 137 | * 138 | * @author Dominik Habichtsberg 139 | * @since 24 Mai 2015 10:25 Uhr 140 | * The base-url should always have a path 141 | */ 142 | if ( empty( $uri->path ) ) { 143 | $uri->path = '/'; 144 | } 145 | 146 | return $uri; 147 | } 148 | 149 | /** 150 | * Join with a relative url 151 | * 152 | * @param string $relative the relative url to join 153 | * 154 | * @return string 155 | */ 156 | public function join( $relative ) { 157 | $uri = new PhpUri( $relative ); 158 | switch ( true ) { 159 | case ! empty( $uri->scheme ): 160 | break; 161 | 162 | case ! empty( $uri->authority ): 163 | break; 164 | 165 | case empty( $uri->path ): 166 | $uri->path = $this->path; 167 | if ( empty( $uri->query ) ) { 168 | $uri->query = $this->query; 169 | } 170 | break; 171 | 172 | case strpos( $uri->path, '/' ) === 0: 173 | break; 174 | 175 | default: 176 | $base_path = $this->path; 177 | if ( strpos( $base_path, '/' ) === false ) { 178 | $base_path = ''; 179 | } else { 180 | $base_path = preg_replace( '/\/[^\/]+$/', '/', $base_path ); 181 | } 182 | if ( empty( $base_path ) && empty( $this->authority ) ) { 183 | $base_path = '/'; 184 | } 185 | $uri->path = $base_path . $uri->path; 186 | } 187 | 188 | if ( empty( $uri->scheme ) ) { 189 | $uri->scheme = $this->scheme; 190 | if ( empty( $uri->authority ) ) { 191 | $uri->authority = $this->authority; 192 | } 193 | } 194 | 195 | return $uri->to_str(); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /js/admin-generate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | jQuery( document ).ready( function( $ ) { 3 | var REFRESH_EVERY_X_SECONDS = 2; 4 | var STATIC_PAGES_PER_PAGE = 50; // max number of pages to show at once 5 | var done = true; 6 | var refreshTimer = null; 7 | 8 | // display the export and activity log on page load 9 | display_export_log(); 10 | display_activity_log(); 11 | initiate_action(); 12 | 13 | $( '#sistContainer #generate' ).click( function( e ) { 14 | $( '#sistContainer #activityLog' ).html(''); 15 | initiate_action( 'start' ); 16 | } ); 17 | 18 | $( '#sistContainer #cancel' ).click( function( e ) { 19 | initiate_action( 'cancel' ); 20 | } ); 21 | 22 | // disable all actions and show spinner 23 | function initiate_action( action ) { 24 | if ( action == null ) { 25 | action = 'ping'; 26 | } else { 27 | $( '#sistContainer .actions input' ).attr( 'disabled', 'disabled' ); 28 | $( '#sistContainer .actions .spinner' ).addClass( 'is-active' ); 29 | } 30 | 31 | // cancel existing timer 32 | if ( refreshTimer != null ) { 33 | clearInterval( refreshTimer ); 34 | } 35 | // send action now 36 | send_action_to_archive_manager( action ); 37 | // set loop for pinging server 38 | refreshTimer = setInterval( function() { 39 | send_action_to_archive_manager( 'ping' ); 40 | }, REFRESH_EVERY_X_SECONDS * 1000 ); 41 | } 42 | 43 | // where action is one of 'start', 'continue', 'cancel' 44 | function send_action_to_archive_manager( action ) { 45 | var data = { 46 | '_ajax_nonce': $('#_wpnonce').val(), 47 | 'action': 'static_archive_action', 48 | 'perform': action 49 | }; 50 | 51 | $.post( window.ajaxurl, data, function( response ) { 52 | handle_response_from_archive_manager( response ); 53 | } ); 54 | } 55 | 56 | function handle_response_from_archive_manager( response ) { 57 | // loop through the responses and create an .activity div for each one 58 | // in #activityLog 59 | var $activityLog = $( '#activityLog' ); 60 | $activityLog.html( response.activity_log_html ) 61 | .scrollTop( $activityLog.prop( 'scrollHeight' ) ); 62 | if ( response.done == true && done == false ) { 63 | display_export_log(); 64 | } 65 | 66 | done = response.done; 67 | 68 | // only adjust the button/spinner state on a 'ping' 69 | // (ensures that the job has had time to process the action) 70 | if ( response.action == 'ping' ) { 71 | // re-enable and hide all actions 72 | $( '#sistContainer .actions input' ) 73 | .removeAttr( 'disabled' ) 74 | .addClass( 'hide' ); 75 | 76 | if ( done == true ) { 77 | // remove spinner and show #generate 78 | $( '#sistContainer .actions .spinner' ).removeClass( 'is-active' ); 79 | $( '#sistContainer #generate' ).removeClass( 'hide' ); 80 | } else { 81 | $( '#sistContainer #cancel' ).removeClass( 'hide' ); 82 | } 83 | } 84 | } 85 | 86 | function display_export_log() { 87 | var data = { 88 | '_ajax_nonce': $('#_wpnonce').val(), 89 | 'action': 'render_export_log', 90 | 'page': 1, 91 | 'per_page': STATIC_PAGES_PER_PAGE 92 | }; 93 | 94 | var $exportLog = $( '#exportLog' ); 95 | $exportLog.html( "" ); 96 | 97 | $.post( window.ajaxurl, data, function( response ) { 98 | $exportLog.html( response.html ); 99 | } ); 100 | } 101 | 102 | function display_activity_log() { 103 | var data = { 104 | '_ajax_nonce': $('#_wpnonce').val(), 105 | 'action': 'render_activity_log' 106 | }; 107 | 108 | var $activityLog = $( '#activityLog' ); 109 | $activityLog.html( "" ); 110 | 111 | $.post( window.ajaxurl, data, function( response ) { 112 | $activityLog.html( response.html ) 113 | .scrollTop( $activityLog.prop( 'scrollHeight' ) ); 114 | } ); 115 | } 116 | 117 | // -- AJAX pagination ----------------------------------------------------// 118 | $( '#sistContainer #exportLog' ).on( 'click', 'a.page-numbers', function( e ) { 119 | e.preventDefault(); 120 | 121 | var url = $( this ).attr( 'href' ); 122 | var re = /page=(\d+)/; 123 | var matches = re.exec( url ); 124 | 125 | var page = 1; 126 | if ( matches ) { 127 | page = matches[1]; 128 | } 129 | 130 | var data = { 131 | '_ajax_nonce': $('#_wpnonce').val(), 132 | 'action': 'render_export_log', 133 | 'page': page, 134 | 'per_page': STATIC_PAGES_PER_PAGE 135 | }; 136 | 137 | $.post( window.ajaxurl, data, function( response ) { 138 | $( '#exportLog' ).html( response.html ); 139 | } ); 140 | } ); 141 | 142 | } ); 143 | -------------------------------------------------------------------------------- /js/admin-settings.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | jQuery( document ).ready( function( $ ) { 3 | // show / hide tabs: 4 | $( '#sistContainer #sistTabs' ).find( 'a' ).click( function() { 5 | $( '#sistContainer #sistTabs' ).find( 'a' ).removeClass( 'nav-tab-active' ); 6 | $( '#sistContainer .tab-pane' ).removeClass( 'active' ); 7 | 8 | var id = $( this ).attr( 'id' ).replace( '-tab', '' ); 9 | $( '#sistContainer #' + id ).addClass( 'active' ); 10 | $( this ).addClass( 'nav-tab-active' ); 11 | } ); 12 | 13 | // set active tab on page load: 14 | var activeTab = window.location.hash.replace( '#tab-', '' ); 15 | 16 | // if no tab hash, default to the first tab 17 | if ( activeTab === '' ) { 18 | activeTab = $( '#sistContainer .tab-pane' ).attr( 'id' ); 19 | } 20 | 21 | $( '#sistContainer #' + activeTab ).addClass( 'active' ); 22 | $( '#sistContainer #' + activeTab + '-tab' ).addClass( 'nav-tab-active' ); 23 | 24 | // pretend the user clicked on the active tab 25 | $( '#sistContainer .nav-tab-active' ).click(); 26 | 27 | // ---------------------------------------------------------------------- // 28 | 29 | // delivery method selection: 30 | $( '#sistContainer #deliveryMethod' ).change( function() { 31 | var selected = $( this ).val(); 32 | $( '#sistContainer .delivery-method' ).removeClass( 'active' ); 33 | $( '#sistContainer .' + selected + '.delivery-method' ).addClass( 'active '); 34 | } ); 35 | 36 | // pretend the user selected a value 37 | $( '#sistContainer #deliveryMethod' ).change(); 38 | 39 | // ---------------------------------------------------------------------- // 40 | 41 | $( 'td.url-dest-option' ).click( function() { 42 | destination_url_type_change( $( this ) ); 43 | } ); 44 | 45 | $( '#sistContainer input[type=radio][name=destination_url_type]' ).change( function() { 46 | destination_url_type_change( $( this ).closest( 'td.url-dest-option' ) ); 47 | } ); 48 | 49 | // pretend the user selected a value on page load 50 | $( '#sistContainer input[type=radio][name=destination_url_type]:checked' ).change(); 51 | 52 | function destination_url_type_change( $this ) { 53 | $( 'td.url-dest-option' ).removeClass( 'active' ); 54 | $this.addClass( 'active' ); 55 | var $radio = $this.find( 'input[type=radio][name=destination_url_type]' ); 56 | $radio.prop( 'checked', true ); 57 | 58 | if ( $radio.val() == 'absolute' ) { 59 | $( '#destinationHost' ) 60 | .prop( 'disabled', false ); 61 | $( '#destinationScheme' ) 62 | .prop( 'disabled', false ); 63 | } else { 64 | $( '#destinationHost' ) 65 | .val('') 66 | .prop( 'disabled', true ); 67 | $( '#destinationScheme' ) 68 | .prop( 'disabled', true ) 69 | } 70 | 71 | if ( $radio.val() == 'relative' ) { 72 | $( '#relativePath' ) 73 | .prop( 'disabled', false ); 74 | } else { 75 | $( '#relativePath' ) 76 | .val('') 77 | .prop( 'disabled', true ); 78 | } 79 | } 80 | 81 | // ---------------------------------------------------------------------- // 82 | 83 | $( '#AddUrlToExclude' ).click( function() { 84 | var $last_row = $( '.excludable-url-row' ).last(); 85 | var $clone_row = $( '#excludableUrlRowTemplate' ).clone().removeAttr( 'id' ); 86 | 87 | var timestamp = new Date().getTime(); 88 | var regex = /excludable\[0\]/g; 89 | 90 | $clone_row.html( $clone_row.html().replace( regex, 'excludable[' + timestamp + ']' ) ); 91 | $clone_row.insertAfter( $last_row ); 92 | } ); 93 | 94 | $( '#excludableUrlRows' ).on( 'click', '.remove-excludable-url-row', function() { 95 | var $row = $( this ).closest( '.excludable-url-row' ); 96 | $row.remove(); 97 | } ); 98 | 99 | // ---------------------------------------------------------------------- // 100 | 101 | $( '#basicAuthCredentialsSaved > a' ).click( function(e) { 102 | e.preventDefault(); 103 | $( '#basicAuthSet' ).addClass( 'hide' ); 104 | $( '#basicAuthUserPass').removeClass( 'hide' ).find( 'input' ).prop( 'disabled', false ); 105 | }); 106 | } ); 107 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/phpstan/phpstan/conf/bleedingEdge.neon 3 | - vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon 4 | - vendor/szepeviktor/phpstan-wordpress/extension.neon 5 | parameters: 6 | level: max 7 | inferPrivatePropertyTypeFromConstructor: true 8 | paths: 9 | - %currentWorkingDirectory%/src/ 10 | scanFiles: 11 | - %currentWorkingDirectory%/tests/phpstan/bootstrap.php 12 | - %currentWorkingDirectory%/tests/phpstan/wp-cli-stubs-2.2.0.php 13 | ignoreErrors: 14 | - '#^Access to an undefined property SimplerStatic\\Page::\$url#' 15 | - '#^Access to an undefined property SimplerStatic\\Page::\$http_status_code#' 16 | - '#^Access to private property SimplerStatic\\Page::\$last_checked_at#' 17 | - '#^Call to an undefined method SimplerStatic\\Query::save\(\)#' 18 | - '#^Call to an undefined method SimplerStatic\\Query::set_status_message\(\)#' 19 | - '#^Access to an undefined property SimplerStatic\\Query::\$found_on_id#' 20 | - '#^Access to an undefined property SimplerStatic\\Query::\$updated_at#' 21 | - '#^Access to an undefined property SimplerStatic\\Model::\$id#' 22 | - '#^Access to an undefined property SimplerStatic\\Model::\$updated_at#' 23 | - '#^Access to an undefined property SimplerStatic\\Model::\$created_at#' 24 | - '#^Access to an undefined property SimplerStatic\\Page::\$id#' 25 | - '#^Access to an undefined property SimplerStatic\\Fetch_Urls_Task::\$archive_dir#' 26 | - '#^Access to an undefined property SimplerStatic\\Fetch_Urls_Task::\$archive_start_time#' 27 | excludes_analyse: 28 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Simpler Static === 2 | Contributors: leonstafford 3 | Tags: html, static website generator, static site, secure, fast 4 | Requires at least: 4.0 5 | Tested up to: 5.4.1 6 | Stable tag: 0.2 7 | License: The Unlicense 8 | License URI: https://unlicense.org 9 | 10 | A simple WordPress static site generator 11 | 12 | == Description == 13 | 14 | Simpler Static is based off the long dormant Simply Static plugin. 15 | 16 | = Security = 17 | 18 | Run on your local computer or on a non-public webserver for best security. 19 | 20 | = Performance = 21 | 22 | 23 | 24 | = Other Similar Plugins = 25 | 26 | In the event that Simpler Static doesn't meet your needs, give this plugin a try: 27 | 28 | - [Static HTML Output](https://wordpress.org/plugins/static-html-output-plugin/) 29 | - [WP2Static](https://wp2static.com) 30 | - [Simply Static](https://wordpress.org/plugins/simply-static) 31 | 32 | == Installation == 33 | 34 | Download Zip installer and upload via WordPress > Plugins > Add new 35 | 36 | == Frequently Asked Questions == 37 | 38 | = I have questions = 39 | 40 | Please visit the [official homepage](https://simplerstatic.com). 41 | 42 | == Changelog == 43 | 44 | = 0.2, May 17, 2020 = 45 | 46 | * Code tidy-up, static analysis, minor tweaks 47 | 48 | = 0.1, May 16, 2020 = 49 | 50 | * First new build, based off fork of SimplyStatic 51 | 52 | -------------------------------------------------------------------------------- /simplerstatic.php: -------------------------------------------------------------------------------- 1 | 'Simpler Static requires PHP 7.3 or higher

" ); 26 | exit(); 27 | } 28 | 29 | deactivate_plugins( __FILE__ ); 30 | } 31 | } else { 32 | define( 'SIMPLERSTATIC_PATH', plugin_dir_path( __FILE__ ) ); 33 | 34 | if ( file_exists( SIMPLERSTATIC_PATH . 'vendor/autoload.php' ) ) { 35 | require_once SIMPLERSTATIC_PATH . 'vendor/autoload.php'; 36 | } 37 | // Loading up Simpler Static in a separate file so that there's nothing to 38 | // trigger a PHP error in this file (e.g. by using namespacing) 39 | SimplerStatic\Plugin::instance(); 40 | } 41 | -------------------------------------------------------------------------------- /src/Archive_Creation_Job.php: -------------------------------------------------------------------------------- 1 | options = Options::instance(); 48 | 49 | // TODO: this may be phpstan caching, doesn't make sense to me 50 | // @phpstan-ignore-next-line 51 | $this->task_list = apply_filters( 52 | 'simplerstatic_archive_creation_job_task_list', 53 | [], 54 | $this->options->get( 'delivery_method' ) 55 | ); 56 | 57 | if ( ! $this->is_job_done() ) { 58 | register_shutdown_function( [ $this, 'shutdown_handler' ] ); 59 | } 60 | 61 | parent::__construct(); 62 | } 63 | 64 | /** 65 | * Helper method for starting the Archive_Creation_Job 66 | * 67 | * @return boolean true if we were able to successfully start generating an archive 68 | */ 69 | public function start() { 70 | if ( $this->is_job_done() ) { 71 | Util::debug_log( 'Starting a job; no job is presently running' ); 72 | Util::debug_log( "Here's our task list: " . implode( ', ', $this->task_list ) ); 73 | 74 | global $blog_id; 75 | 76 | $first_task = $this->task_list[0]; 77 | $archive_name = join( '-', [ Plugin::SLUG, $blog_id, time() ] ); 78 | 79 | $this->options 80 | ->set( 'archive_status_messages', [] ) 81 | ->set( 'archive_name', $archive_name ) 82 | ->set( 'archive_start_time', Util::formatted_datetime() ) 83 | ->set( 'archive_end_time', null ) 84 | ->save(); 85 | 86 | Util::debug_log( 'Pushing first task to queue: ' . $first_task ); 87 | 88 | $this->push_to_queue( $first_task ) 89 | ->save() 90 | ->dispatch(); 91 | 92 | return true; 93 | } else { 94 | Util::debug_log( "Not starting; we're already in the middle of a job" ); 95 | // looks like we're in the middle of creating an archive... 96 | return false; 97 | } 98 | } 99 | 100 | /** 101 | * Perform the task at hand 102 | * 103 | * The way Archive_Creation_Job works is by taking a task name, performing 104 | * that task, and then either (a) returnning the current task name to 105 | * continue processing it (e.g. fetch more urls), (b) returning the next 106 | * task name if we're done with the current one, or (c) returning false if 107 | * we're done with our job, which then runs complete(). 108 | * 109 | * @param string $task_name Task name to process 110 | * @return false|string task name to process, or false if done 111 | */ 112 | protected function task( $task_name ) { 113 | $this->set_current_task( $task_name ); 114 | 115 | Util::debug_log( 'Current task: ' . $task_name ); 116 | 117 | // convert 'an_example' to 'An_Example_Task' 118 | $class_name = 'SimplerStatic\\' . ucwords( $task_name ) . '_Task'; 119 | 120 | // quick patch for updated class names 121 | if ( $class_name === 'SimplerStatic\Fetch_urls_Task' ) { 122 | $class_name = 'SimplerStatic\Fetch_Urls_Task'; 123 | } 124 | 125 | if ( $class_name === 'SimplerStatic\Create_zip_archive_Task' ) { 126 | $class_name = 'SimplerStatic\Create_Zip_Archive_Task'; 127 | } 128 | 129 | // this shouldn't ever happen, but just in case... 130 | if ( ! class_exists( $class_name ) ) { 131 | $this->save_status_message( "Class doesn't exist: " . $class_name, 'error' ); 132 | return false; 133 | } 134 | 135 | $task = new $class_name(); 136 | 137 | // attempt to perform the task 138 | try { 139 | Util::debug_log( 'Performing task: ' . $task_name ); 140 | $is_done = $task->perform(); 141 | } catch ( SimplerStaticException $e ) { 142 | Util::debug_log( 'Caught an exception' ); 143 | return $this->exception_occurred( $e ); 144 | } 145 | 146 | if ( is_wp_error( $is_done ) ) { 147 | // we've hit an error, time to quit 148 | Util::debug_log( 'We encountered a WP_Error' ); 149 | return $this->error_occurred( $is_done ); 150 | } elseif ( $is_done === true ) { 151 | // finished current task, try to find the next one 152 | $next_task = $this->find_next_task(); 153 | if ( $next_task === null ) { 154 | Util::debug_log( 155 | 'This task is done and there are no more tasks, time to complete the job' 156 | ); 157 | // we're done; returning false to remove item from queue 158 | return false; 159 | } else { 160 | Util::debug_log( "We've found our next task: " . $next_task ); 161 | // start the next task 162 | return $next_task; 163 | } 164 | } else { 165 | Util::debug_log( "We're not done with the " . $task_name . ' task yet' ); 166 | // returning current task name to continue processing 167 | return $task_name; 168 | } 169 | } 170 | 171 | /** 172 | * This is run at the end of the job, after task() has returned false 173 | * 174 | * @return void 175 | */ 176 | protected function complete() { 177 | Util::debug_log( 'Completing the job' ); 178 | 179 | $this->set_current_task( 'done' ); 180 | 181 | $end_time = (string) Util::formatted_datetime(); 182 | $start_time = $this->options->get( 'archive_start_time' ); 183 | $duration = strtotime( $end_time ) - strtotime( $start_time ); 184 | $time_string = gmdate( 'H:i:s', $duration ); 185 | 186 | $this->options->set( 'archive_end_time', $end_time ); 187 | 188 | $this->save_status_message( sprintf( 'Done! Finished in %s', $time_string ) ); 189 | parent::complete(); 190 | } 191 | 192 | 193 | /** 194 | * Cancel the currently running job 195 | * 196 | * @return void 197 | */ 198 | public function cancel() { 199 | if ( ! $this->is_job_done() ) { 200 | Util::debug_log( 'Cancelling job; job is not done' ); 201 | 202 | if ( $this->is_queue_empty() ) { 203 | Util::debug_log( 'The queue is empty, pushing the cancel task' ); 204 | // generally the queue shouldn't be empty when we get a request to 205 | // cancel, but if we do, add a cancel task and start processing it. 206 | // that should get the job back into a state where it can be 207 | // started again. 208 | $this->push_to_queue( 'cancel' ) 209 | ->save(); 210 | } else { 211 | Util::debug_log( 212 | "The queue isn't empty; overwriting current task with a cancel task" 213 | ); 214 | 215 | // unlock the process so that we can force our cancel task to process 216 | $this->unlock_process(); 217 | 218 | // overwrite whatever the current task is with the cancel task 219 | $batch = $this->get_batch(); 220 | $batch->data = [ 'cancel' ]; 221 | $this->update( $batch->key, $batch->data ); 222 | } 223 | 224 | $this->dispatch(); 225 | } else { 226 | Util::debug_log( "Can't cancel; job is done" ); 227 | } 228 | } 229 | 230 | /** 231 | * Is the job done? 232 | * 233 | * @return boolean True if done, false if not 234 | */ 235 | public function is_job_done() { 236 | $start_time = $this->options->get( 'archive_start_time' ); 237 | $end_time = $this->options->get( 'archive_end_time' ); 238 | // we're done if the start and end time are null (never run) or if 239 | // the start and end times are both set 240 | return ( $start_time == null && $end_time == null ) || 241 | ( $start_time != null && $end_time != null ); 242 | } 243 | 244 | /** 245 | * Return the current task 246 | * 247 | * @return string The current task 248 | */ 249 | public function get_current_task() { 250 | return $this->current_task; 251 | } 252 | 253 | /** 254 | * Set the current task name 255 | * 256 | * @param string $task_name The name of the current task 257 | */ 258 | protected function set_current_task( $task_name ) : void { 259 | $this->current_task = $task_name; 260 | } 261 | 262 | /** 263 | * Find the next task on our task list 264 | * 265 | * @return string|null The name of the next task, or null if none 266 | */ 267 | protected function find_next_task() { 268 | $task_name = $this->get_current_task(); 269 | $index = (int) array_search( $task_name, $this->task_list ); 270 | ++$index; 271 | 272 | if ( ! isset( $this->task_list[ $index ] ) ) { 273 | return null; 274 | } else { 275 | return $this->task_list[ $index ]; 276 | } 277 | } 278 | 279 | /** 280 | * Add a message to the array of status messages for the job 281 | * 282 | * Providing a unique key for the message is optional. If one isn't 283 | * provided, the state_name will be used. Using the same key more than once 284 | * will overwrite previous messages. 285 | * 286 | * @param string $message Message to display about the status of the job 287 | * @param string $key Unique key for the message 288 | * @return void 289 | */ 290 | protected function save_status_message( $message, $key = null ) { 291 | $task_name = $key ? $key : $this->get_current_task(); 292 | $messages = $this->options->get( 'archive_status_messages' ); 293 | Util::debug_log( 'Status message: [' . $task_name . '] ' . $message ); 294 | 295 | $messages = Util::add_archive_status_message( $messages, $task_name, $message ); 296 | 297 | $this->options 298 | ->set( 'archive_status_messages', $messages ) 299 | ->save(); 300 | } 301 | 302 | /** 303 | * Add a status message about the exception and cancel the job 304 | * 305 | * @param SimplerStaticException $exception The exception that occurred 306 | */ 307 | protected function exception_occurred( $exception ) : string { 308 | Util::debug_log( 'An exception occurred: ' . $exception->getMessage() ); 309 | Util::debug_log( $exception ); 310 | $message = 311 | sprintf( 'An exception occurred: %s', $exception->getMessage() ); 312 | $this->save_status_message( $message, 'error' ); 313 | return 'cancel'; 314 | } 315 | 316 | /** 317 | * Add a status message about the error and cancel the job 318 | * 319 | * @param \WP_Error $wp_error The error that occurred 320 | */ 321 | protected function error_occurred( $wp_error ) : string { 322 | Util::debug_log( 'An error occurred: ' . $wp_error->get_error_message() ); 323 | Util::debug_log( $wp_error ); 324 | $message = sprintf( 'An error occurred: %s', $wp_error->get_error_message() ); 325 | $this->save_status_message( $message, 'error' ); 326 | return 'cancel'; 327 | } 328 | 329 | /** 330 | * Shutdown handler for fatal error reporting 331 | * 332 | * @return void 333 | */ 334 | public function shutdown_handler() { 335 | // Note: this function must be public in order to function properly. 336 | $error = error_get_last(); 337 | // only trigger on actual errors, not warnings or notices 338 | if ( $error && in_array( $error['type'], [ E_ERROR, E_CORE_ERROR, E_USER_ERROR ] ) ) { 339 | $this->clear_scheduled_event(); 340 | $this->unlock_process(); 341 | $this->cancel_process(); 342 | 343 | $end_time = Util::formatted_datetime(); 344 | $this->options 345 | ->set( 'archive_end_time', $end_time ) 346 | ->save(); 347 | 348 | $error_message = '(' . $error['type'] . ') ' . $error['message']; 349 | $error_message .= ' in ' . $error['file'] . ''; 350 | $error_message .= ' on line ' . $error['line'] . ''; 351 | 352 | $message = sprintf( 'Error: %s', $error_message ); 353 | Util::debug_log( $message ); 354 | $this->save_status_message( $message, 'error' ); 355 | } 356 | } 357 | 358 | } 359 | -------------------------------------------------------------------------------- /src/Cancel_Task.php: -------------------------------------------------------------------------------- 1 | save_status_message( __( 'Cancelling job', 'simplerstatic' ) ); 13 | 14 | $wrapup_task = new Wrapup_Task(); 15 | $wrapup_task->perform(); 16 | 17 | return true; 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/Create_Zip_Archive_Task.php: -------------------------------------------------------------------------------- 1 | create_zip(); 17 | if ( is_wp_error( $download_url ) ) { 18 | return $download_url; 19 | } else { 20 | $message = 'ZIP archive created: '; 21 | $message .= " Click here to download"; 22 | $this->save_status_message( $message ); 23 | return true; 24 | } 25 | } 26 | 27 | /** 28 | * Create a ZIP file using the archive directory 29 | * 30 | * @return string|\WP_Error $temporary_zip The path to the archive zip file 31 | */ 32 | public function create_zip() { 33 | $archive_dir = $this->options->get_archive_dir(); 34 | 35 | $zip_filename = untrailingslashit( $archive_dir ) . '.zip'; 36 | $zip_archive = new ZipArchive(); 37 | $zip_archive->open( $zip_filename, ZipArchive::CREATE ); 38 | 39 | Util::debug_log( 'Fetching list of files to include in zip' ); 40 | $files = []; 41 | $iterator = 42 | new RecursiveIteratorIterator( 43 | new RecursiveDirectoryIterator( 44 | $archive_dir, 45 | RecursiveDirectoryIterator::SKIP_DOTS 46 | ) 47 | ); 48 | Util::debug_log( 'Creating zip archive' ); 49 | 50 | foreach ( $iterator as $file_name => $file_object ) { 51 | if ( 52 | ! $zip_archive->addFile( $file_object, str_replace( $archive_dir, '', $file_name ) ) 53 | ) { 54 | return new \WP_Error( 'create_zip_failed', 'Unable to create ZIP archive' ); 55 | } 56 | } 57 | 58 | $download_url = get_admin_url( null, 'admin.php' ) . '?' . 59 | Plugin::SLUG . '_zip_download=' . basename( $zip_filename ); 60 | 61 | return $download_url; 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/Diagnostic.php: -------------------------------------------------------------------------------- 1 | '7.3.0', 22 | 'curl' => '7.15.0', 23 | ]; 24 | 25 | /** 26 | * Assoc. array of categories, and then functions to check 27 | * 28 | * @var mixed[] 29 | */ 30 | protected $description = [ 31 | 'URLs' => [], 32 | 'Filesystem' => [ 33 | [ 'function' => 'is_temp_files_dir_readable' ], 34 | [ 'function' => 'is_temp_files_dir_writeable' ], 35 | ], 36 | 'WordPress' => [ 37 | [ 'function' => 'is_permalink_structure_set' ], 38 | [ 'function' => 'can_wp_make_requests_to_itself' ], 39 | ], 40 | 'MySQL' => [ 41 | [ 'function' => 'user_can_delete' ], 42 | [ 'function' => 'user_can_insert' ], 43 | [ 'function' => 'user_can_select' ], 44 | [ 'function' => 'user_can_create' ], 45 | [ 'function' => 'user_can_alter' ], 46 | [ 'function' => 'user_can_drop' ], 47 | ], 48 | 'PHP' => [ 49 | [ 'function' => 'php_version' ], 50 | [ 'function' => 'has_curl' ], 51 | ], 52 | ]; 53 | 54 | /** 55 | * Assoc. array of results of the diagnostic check 56 | * 57 | * @var mixed[] 58 | */ 59 | public $results = []; 60 | 61 | /** 62 | * An instance of the options structure containing all options for this plugin 63 | * 64 | * @var Options 65 | */ 66 | protected $options = null; 67 | 68 | public function __construct() { 69 | $this->options = Options::instance(); 70 | 71 | if ( $this->options->get( 'destination_url_type' ) == 'absolute' ) { 72 | $this->description['URLs'][] = [ 73 | 'function' => 'is_destination_host_a_valid_url', 74 | ]; 75 | } 76 | 77 | if ( $this->options->get( 'delivery_method' ) == 'local' ) { 78 | $this->description['Filesystem'][] = [ 79 | 'function' => 'is_local_dir_writeable', 80 | ]; 81 | } 82 | 83 | $additional_urls = Util::string_to_array( $this->options->get( 'additional_urls' ) ); 84 | foreach ( $additional_urls as $url ) { 85 | $this->description['URLs'][] = [ 86 | 'function' => 'is_additional_url_valid', 87 | 'param' => $url, 88 | ]; 89 | } 90 | 91 | $additional_files = Util::string_to_array( $this->options->get( 'additional_files' ) ); 92 | foreach ( $additional_files as $file ) { 93 | $this->description['Filesystem'][] = [ 94 | 'function' => 'is_additional_file_valid', 95 | 'param' => $file, 96 | ]; 97 | } 98 | 99 | foreach ( $this->description as $title => $tests ) { 100 | $this->results[ $title ] = []; 101 | foreach ( $tests as $test ) { 102 | $param = isset( $test['param'] ) ? $test['param'] : null; 103 | $result = $this->{$test['function']}( $param ); 104 | 105 | if ( ! isset( $result['message'] ) ) { 106 | $result['message'] = 107 | $result['test'] ? 'OK' : 'FAIL'; 108 | } 109 | 110 | $this->results[ $title ][] = $result; 111 | } 112 | } 113 | } 114 | 115 | /** 116 | * @return mixed[] 117 | */ 118 | public function is_destination_host_a_valid_url() { 119 | $destination_scheme = $this->options->get( 'destination_scheme' ); 120 | $destination_host = $this->options->get( 'destination_host' ); 121 | $destination_url = $destination_scheme . $destination_host; 122 | $label = sprintf( 123 | 'Checking if Destination URL %s is valid', 124 | $destination_url 125 | ); 126 | return [ 127 | 'label' => $label, 128 | 'test' => filter_var( $destination_url, FILTER_VALIDATE_URL ) !== false, 129 | ]; 130 | } 131 | 132 | /** 133 | * @return mixed[] 134 | */ 135 | public function is_additional_url_valid( string $url ) { 136 | $label = sprintf( 'Checking if Additional URL %s is valid', $url ); 137 | if ( filter_var( $url, FILTER_VALIDATE_URL ) === false ) { 138 | $test = false; 139 | $message = 'Not a valid URL'; 140 | } elseif ( ! Util::is_local_url( $url ) ) { 141 | $test = false; 142 | $message = 'Not a local URL'; 143 | } else { 144 | $test = true; 145 | $message = null; 146 | } 147 | 148 | return [ 149 | 'label' => $label, 150 | 'test' => $test, 151 | 'message' => $message, 152 | ]; 153 | } 154 | 155 | /** 156 | * @return mixed[] 157 | */ 158 | public function is_additional_file_valid( string $file ) { 159 | $label = sprintf( 'Checking if Additional File/Dir %s is valid', $file ); 160 | if ( 161 | stripos( $file, get_home_path() ) !== 0 && 162 | stripos( $file, WP_PLUGIN_DIR ) !== 0 && 163 | stripos( $file, WP_CONTENT_DIR ) !== 0 164 | ) { 165 | $test = false; 166 | $message = 'Not a valid path'; 167 | } elseif ( ! is_readable( $file ) ) { 168 | $test = false; 169 | $message = 'Not readable'; 170 | } else { 171 | $test = true; 172 | $message = null; 173 | } 174 | 175 | return [ 176 | 'label' => $label, 177 | 'test' => $test, 178 | 'message' => $message, 179 | ]; 180 | } 181 | 182 | /** 183 | * @return mixed[] 184 | */ 185 | public function is_permalink_structure_set() { 186 | $label = 'Checking if WordPress permalink structure is set'; 187 | return [ 188 | 'label' => $label, 189 | 'test' => strlen( get_option( 'permalink_structure' ) ) !== 0, 190 | ]; 191 | } 192 | 193 | /** 194 | * @return mixed[] 195 | */ 196 | public function can_wp_make_requests_to_itself() { 197 | $ip_address = getHostByName( (string) getHostName() ); 198 | $label = sprintf( 199 | 'Checking if WordPress can make requests to itself from %s', 200 | $ip_address 201 | ); 202 | 203 | $url = Util::origin_url(); 204 | $response = Url_Fetcher::remote_get( $url ); 205 | 206 | if ( is_wp_error( $response ) ) { 207 | $test = false; 208 | $message = null; 209 | } else { 210 | $code = $response['response']['code']; 211 | if ( $code == 200 ) { 212 | $test = true; 213 | $message = $code; 214 | } elseif ( in_array( $code, Page::$processable_status_codes ) ) { 215 | $test = false; 216 | $message = 217 | sprintf( 'Received a %s response. This might indicate a problem.', $code ); 218 | } else { 219 | $test = false; 220 | $message = sprintf( 'Received a %s response.', $code ); 221 | 222 | } 223 | } 224 | 225 | return [ 226 | 'label' => $label, 227 | 'test' => $test, 228 | 'message' => $message, 229 | ]; 230 | } 231 | 232 | /** 233 | * @return mixed[] 234 | */ 235 | public function is_temp_files_dir_readable() { 236 | $temp_files_dir = $this->options->get( 'temp_files_dir' ); 237 | $label = sprintf( 238 | 'Checking if web server can read from Temp Files Directory: %s', 239 | $temp_files_dir 240 | ); 241 | 242 | return [ 243 | 'label' => $label, 244 | 'test' => is_readable( $temp_files_dir ), 245 | ]; 246 | } 247 | 248 | /** 249 | * @return mixed[] 250 | */ 251 | public function is_temp_files_dir_writeable() { 252 | $temp_files_dir = $this->options->get( 'temp_files_dir' ); 253 | $label = sprintf( 254 | 'Checking if web server can write to Temp Files Directory: %s', 255 | $temp_files_dir 256 | ); 257 | 258 | return [ 259 | 'label' => $label, 260 | 'test' => is_writable( $temp_files_dir ), 261 | ]; 262 | } 263 | 264 | /** 265 | * @return mixed[] 266 | */ 267 | public function is_local_dir_writeable() { 268 | $local_dir = $this->options->get( 'local_dir' ); 269 | $label = sprintf( 270 | 'Checking if web server can write to Local Directory: %s', 271 | $local_dir 272 | ); 273 | 274 | return [ 275 | 'label' => $label, 276 | 'test' => is_writable( $local_dir ), 277 | ]; 278 | } 279 | 280 | /** 281 | * @return mixed[] 282 | */ 283 | public function user_can_delete() { 284 | $label = __( 'Checking if MySQL user has DELETE privilege', 'simplerstatic' ); 285 | return [ 286 | 'label' => $label, 287 | 'test' => Sql_Permissions::instance()->can( 'delete' ), 288 | ]; 289 | } 290 | 291 | /** 292 | * @return mixed[] 293 | */ 294 | public function user_can_insert() { 295 | $label = __( 'Checking if MySQL user has INSERT privilege', 'simplerstatic' ); 296 | return [ 297 | 'label' => $label, 298 | 'test' => Sql_Permissions::instance()->can( 'insert' ), 299 | ]; 300 | } 301 | 302 | /** 303 | * @return mixed[] 304 | */ 305 | public function user_can_select() { 306 | $label = __( 'Checking if MySQL user has SELECT privilege', 'simplerstatic' ); 307 | return [ 308 | 'label' => $label, 309 | 'test' => Sql_Permissions::instance()->can( 'select' ), 310 | ]; 311 | } 312 | 313 | /** 314 | * @return mixed[] 315 | */ 316 | public function user_can_create() { 317 | $label = __( 'Checking if MySQL user has CREATE privilege', 'simplerstatic' ); 318 | return [ 319 | 'label' => $label, 320 | 'test' => Sql_Permissions::instance()->can( 'create' ), 321 | ]; 322 | } 323 | 324 | /** 325 | * @return mixed[] 326 | */ 327 | public function user_can_alter() { 328 | $label = __( 'Checking if MySQL user has ALTER privilege', 'simplerstatic' ); 329 | return [ 330 | 'label' => $label, 331 | 'test' => Sql_Permissions::instance()->can( 'alter' ), 332 | ]; 333 | } 334 | 335 | /** 336 | * @return mixed[] 337 | */ 338 | public function user_can_drop() { 339 | $label = __( 'Checking if MySQL user has DROP privilege', 'simplerstatic' ); 340 | return [ 341 | 'label' => $label, 342 | 'test' => Sql_Permissions::instance()->can( 'drop' ), 343 | ]; 344 | } 345 | 346 | /** 347 | * @return mixed[] 348 | */ 349 | public function php_version() { 350 | $label = sprintf( 'Checking if PHP version >= %s', self::$min_version['php'] ); 351 | return [ 352 | 'label' => $label, 353 | 'test' => version_compare( (string) phpversion(), self::$min_version['php'], '>=' ), 354 | 'message' => phpversion(), 355 | ]; 356 | } 357 | 358 | /** 359 | * @return mixed[] 360 | */ 361 | public function has_curl() { 362 | $label = __( 'Checking for cURL support', 'simplerstatic' ); 363 | 364 | if ( function_exists( 'curl_version' ) ) { 365 | $version = curl_version(); 366 | $test = version_compare( $version['version'], self::$min_version['curl'], '>=' ); 367 | $message = $version['version']; 368 | } else { 369 | $test = false; 370 | $message = null; 371 | } 372 | 373 | return [ 374 | 'label' => $label, 375 | 'test' => $test, 376 | 'message' => $message, 377 | ]; 378 | } 379 | 380 | } 381 | -------------------------------------------------------------------------------- /src/Fetch_Urls_Task.php: -------------------------------------------------------------------------------- 1 | archive_dir = $this->options->get_archive_dir(); 18 | $this->archive_start_time = $this->options->get( 'archive_start_time' ); 19 | } 20 | 21 | /** 22 | * Fetch and save pages for the static archive 23 | * 24 | * @return bool true if done, false if not done 25 | */ 26 | public function perform() { 27 | // TODO: set as configurable option for performance 28 | $batch_size = 10; 29 | 30 | $static_pages = Page::query() 31 | ->where( 'last_checked_at < ? OR last_checked_at IS NULL', $this->archive_start_time ) 32 | ->limit( $batch_size ) 33 | ->find(); 34 | $pages_remaining = Page::query() 35 | ->where( 'last_checked_at < ? OR last_checked_at IS NULL', $this->archive_start_time ) 36 | ->count(); 37 | $total_pages = Page::query()->count(); 38 | $pages_processed = $total_pages - $pages_remaining; 39 | Util::debug_log( 40 | 'Total pages: ' . $total_pages . '; Pages remaining: ' . $pages_remaining 41 | ); 42 | 43 | while ( $static_page = array_shift( $static_pages ) ) { 44 | Util::debug_log( 'URL: ' . $static_page->url ); 45 | 46 | $excludable = $this->find_excludable( $static_page ); 47 | if ( is_array( $excludable ) ) { 48 | $save_file = $excludable['do_not_save'] !== '1'; 49 | $follow_urls = $excludable['do_not_follow'] !== '1'; 50 | // Util::debug_log( 51 | // "Excludable found: URL: " . $excludable['url'] . ' DNS: ' . 52 | // $excludable['do_not_save'] . ' DNF: ' .$excludable['do_not_follow'] 53 | // ); 54 | } else { 55 | $save_file = true; 56 | $follow_urls = true; 57 | } 58 | 59 | // If we're not saving a copy of the page or following URLs on that 60 | // page, then we don't need to bother fetching it. 61 | if ( $save_file === false && $follow_urls === false ) { 62 | // Util::debug_log( "Skipping URL because it is no-save and no-follow" ); 63 | $static_page->last_checked_at = Util::formatted_datetime(); 64 | $static_page->set_status_message( 'Do not save or follow' ); 65 | $static_page->http_status_code = 666; 66 | $static_page->save(); 67 | continue; 68 | } else { 69 | $success = Url_Fetcher::instance()->fetch( $static_page ); 70 | } 71 | 72 | if ( ! $success ) { 73 | continue; 74 | } 75 | 76 | // If we get a 30x redirect... 77 | if ( in_array( $static_page->http_status_code, [ 301, 302, 303, 307, 308 ] ) ) { 78 | $this->handle_30x_redirect( $static_page, $save_file, $follow_urls ); 79 | continue; 80 | } 81 | 82 | // Not a 200 for the response code? Move on. 83 | if ( $static_page->http_status_code != 200 ) { 84 | continue; 85 | } 86 | 87 | $this->handle_200_response( $static_page, $save_file, $follow_urls ); 88 | } 89 | 90 | $message = 91 | sprintf( 'Fetched %1$d of %2$d pages/files', $pages_processed, $total_pages ); 92 | $this->save_status_message( $message ); 93 | 94 | // if we haven't processed any additional pages, we're done 95 | return $pages_remaining == 0; 96 | } 97 | 98 | /** 99 | * Process the response for a 200 response (success) 100 | * 101 | * @param Page $static_page Record to update 102 | * @param boolean $save_file Save a static copy of the page? 103 | * @param boolean $follow_urls Save found URLs to database? 104 | * @return void 105 | */ 106 | protected function handle_200_response( $static_page, $save_file, $follow_urls ) { 107 | $urls = []; 108 | 109 | if ( $save_file || $follow_urls ) { 110 | // Util::debug_log( "Extracting URLs and replacing URLs in the static file" ); 111 | // Fetch all URLs from the page and add them to the queue... 112 | $extractor = new Url_Extractor( $static_page ); 113 | $urls = $extractor->extract_and_update_urls(); 114 | } 115 | 116 | if ( $follow_urls ) { 117 | Util::debug_log( 'Adding ' . count( $urls ) . ' URLs to the queue' ); 118 | 119 | foreach ( $urls as $url ) { 120 | $this->set_url_found_on( $static_page, $url ); 121 | } 122 | } else { 123 | Util::debug_log( 'Not following URLs from this page' ); 124 | $static_page->set_status_message( 'Do not follow' ); 125 | } 126 | 127 | $file = $this->archive_dir . $static_page->file_path; 128 | if ( $save_file ) { 129 | // Util::debug_log( "We're saving this URL; keeping the static file" ); 130 | $sha1 = (string) sha1_file( $file ); 131 | 132 | // if the content is identical, move on to the next file 133 | if ( ! $static_page->is_content_identical( $sha1 ) ) { 134 | $static_page->set_content_hash( $sha1 ); 135 | } 136 | } else { 137 | // Util::debug_log( "Not saving this URL; deleting the static file" ); 138 | unlink( $file ); // delete saved file 139 | $static_page->file_path = null; 140 | $static_page->set_status_message( 'Do not save' ); 141 | } 142 | 143 | $static_page->save(); 144 | } 145 | 146 | /** 147 | * Process the response to a 30x redirection 148 | * 149 | * @param Page $static_page Record to update 150 | * @param boolean $save_file Save a static copy of the page? 151 | * @param boolean $follow_urls Save redirect URL to database? 152 | * @return void 153 | */ 154 | protected function handle_30x_redirect( $static_page, $save_file, $follow_urls ) { 155 | $origin_url = Util::origin_url(); 156 | $destination_url = $this->options->get_destination_url(); 157 | $current_url = $static_page->url; 158 | $redirect_url = $static_page->redirect_url; 159 | 160 | Util::debug_log( 'redirect_url: ' . $redirect_url ); 161 | 162 | // convert our potentially relative URL to an absolute URL 163 | $redirect_url = Util::relative_to_absolute_url( $redirect_url, $current_url ); 164 | 165 | if ( $redirect_url ) { 166 | // WP likes to 301 redirect `/path` to `/path/` -- we want to 167 | // check for this and just add the trailing slashed version 168 | if ( $redirect_url === trailingslashit( $current_url ) ) { 169 | 170 | Util::debug_log( 171 | 'This is a redirect to a trailing slashed version of the same page;' . 172 | ' adding new URL to the queue' 173 | ); 174 | $this->set_url_found_on( $static_page, $redirect_url ); 175 | 176 | // Don't create a redirect page if it's just a redirect from 177 | // http to https. Instead just add the new url to the queue. 178 | // TODO: Make this less horrible. 179 | } elseif ( 180 | Util::strip_index_filenames_from_url( 181 | (string) Util::remove_params_and_fragment( 182 | (string) Util::strip_protocol_from_url( (string) $redirect_url ) 183 | ) 184 | ) === 185 | Util::strip_index_filenames_from_url( 186 | (string) Util::remove_params_and_fragment( 187 | (string) Util::strip_protocol_from_url( (string) $current_url ) 188 | ) 189 | ) 190 | ) { 191 | Util::debug_log( 192 | 'This looks like a redirect from http to https (or visa versa);' . 193 | ' adding new URL to the queue' 194 | ); 195 | $this->set_url_found_on( $static_page, $redirect_url ); 196 | 197 | } else { 198 | // check if this is a local URL 199 | if ( Util::is_local_url( $redirect_url ) ) { 200 | 201 | if ( $follow_urls ) { 202 | Util::debug_log( 203 | 'Redirect URL is on the same domain; adding the URL to the queue' 204 | ); 205 | $this->set_url_found_on( $static_page, $redirect_url ); 206 | } else { 207 | Util::debug_log( 'Not following the redirect URL for this page' ); 208 | $static_page->set_status_message( __( 'Do not follow', 'simplerstatic' ) ); 209 | } 210 | // and update the URL 211 | $redirect_url = str_replace( $origin_url, $destination_url, $redirect_url ); 212 | 213 | } 214 | 215 | if ( $save_file ) { 216 | Util::debug_log( 'Creating a redirect page' ); 217 | 218 | $view = new View(); 219 | 220 | $content = $view->set_template( 'redirect' ) 221 | ->assign( 'redirect_url', $redirect_url ) 222 | ->render_to_string(); 223 | 224 | if ( ! is_string( $content ) ) { 225 | $content = ''; 226 | } 227 | 228 | $filename = $this->save_static_page_content_to_file( 229 | $static_page, 230 | $content 231 | ); 232 | 233 | if ( is_string( $filename ) ) { 234 | $static_page->file_path = $filename; 235 | } 236 | 237 | $sha1 = (string) sha1_file( $this->archive_dir . $filename ); 238 | 239 | // if the content is identical, move on to the next file 240 | if ( ! $static_page->is_content_identical( $sha1 ) ) { 241 | $static_page->set_content_hash( $sha1 ); 242 | } 243 | } else { 244 | Util::debug_log( 'Not creating a redirect page' ); 245 | $static_page->set_status_message( 'Do not save' ); 246 | } 247 | 248 | $static_page->save(); 249 | } 250 | } 251 | } 252 | 253 | /** 254 | * @return mixed[]|bool 255 | */ 256 | protected function find_excludable( Page $static_page ) { 257 | $url = $static_page->url; 258 | $excludables = []; 259 | foreach ( $this->options->get( 'urls_to_exclude' ) as $excludable ) { 260 | // using | as the delimiter for regex instead of the traditional / 261 | // because | won't show up in a path (it would have to be url-encoded) 262 | $regex = '|' . $excludable['url'] . '|'; 263 | $result = preg_match( $regex, $url ); 264 | if ( $result === 1 ) { 265 | return $excludable; 266 | } 267 | } 268 | return false; 269 | } 270 | 271 | /** 272 | * Set ID for which page a URL was found on (& create page if not in DB yet) 273 | * 274 | * Given a URL, find the associated Page, and then set the ID 275 | * for which page it was found on if the ID isn't yet set or if the record 276 | * hasn't been updated in this instance of static generation yet. 277 | * 278 | * @param Page $static_page The record for the parent page 279 | * @param string $child_url The URL of the child page 280 | * @return void 281 | */ 282 | protected function set_url_found_on( $static_page, $child_url ) { 283 | $child_static_page = Page::query()->find_or_create_by( 'url', $child_url ); 284 | if ( 285 | $child_static_page->found_on_id === null || 286 | $child_static_page->updated_at < $this->archive_start_time 287 | ) { 288 | $child_static_page->found_on_id = $static_page->id; 289 | $child_static_page->save(); 290 | } 291 | } 292 | 293 | /** 294 | * Save the contents of a page to a file in our archive directory 295 | * 296 | * @param Page $static_page The Page record 297 | * @param string $content The content of the page we want to save 298 | * @return string|void The file path of the saved file 299 | */ 300 | protected function save_static_page_content_to_file( $static_page, $content ) { 301 | $relative_filename = 302 | Url_Fetcher::instance()->create_directories_for_static_page( $static_page ); 303 | 304 | if ( $relative_filename ) { 305 | $file_path = $this->archive_dir . $relative_filename; 306 | 307 | $write = file_put_contents( $file_path, $content ); 308 | if ( $write === false ) { 309 | $static_page->set_error_message( 'Unable to write temporary file' ); 310 | } else { 311 | return $relative_filename; 312 | } 313 | } else { 314 | return; 315 | } 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /src/Model.php: -------------------------------------------------------------------------------- 1 | 'col_definition', e.g. 28 | * 'id' => 'BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY' 29 | * 30 | * @var mixed[] 31 | */ 32 | protected static $columns = []; 33 | 34 | /** 35 | * A list of the indexes for the model 36 | * 37 | * In the format of 'index_name' => 'index_def', e.g. 38 | * 'url' => 'url' 39 | * 40 | * @var mixed[] 41 | */ 42 | protected static $indexes = []; 43 | 44 | /** 45 | * The name of the primary key for the model 46 | * 47 | * @var string 48 | */ 49 | protected static $primary_key = null; 50 | 51 | /** 52 | * The stored data for this instance of the model. 53 | * 54 | * @var mixed[] 55 | */ 56 | private $data = []; 57 | 58 | /** 59 | * Track if this record has had changed made to it 60 | * 61 | * @var mixed[] 62 | */ 63 | private $dirty_fields = []; 64 | 65 | /** 66 | * Retrieve the value of a field for the model 67 | * 68 | * Returns an exception if you try to retrieve a field that isn't set. 69 | * 70 | * @param string $field_name The name of the field to retrieve 71 | * @return mixed The value for the field 72 | * @throws SimplerStaticException 73 | */ 74 | public function __get( $field_name ) { 75 | if ( ! array_key_exists( $field_name, $this->data ) ) { 76 | throw new SimplerStaticException( 'Undefined variable for ' . get_called_class() ); 77 | } else { 78 | return $this->data[ $field_name ]; 79 | } 80 | } 81 | 82 | /** 83 | * Set the value of a field for the model 84 | * 85 | * Returns an exception if you try to set a field that isn't one of the 86 | * model's columns. 87 | * 88 | * @param string $field_name The name of the field to set 89 | * @param mixed $field_value The value for the field 90 | * @return mixed The value of the field that was set 91 | * @throws SimplerStaticException 92 | */ 93 | public function __set( $field_name, $field_value ) { 94 | if ( ! array_key_exists( $field_name, static::$columns ) ) { 95 | throw new SimplerStaticException( 'Column doesn\'t exist for ' . get_called_class() ); 96 | } else { 97 | if ( 98 | ! array_key_exists( $field_name, $this->data ) || 99 | $this->data[ $field_name ] !== $field_value 100 | ) { 101 | array_push( $this->dirty_fields, $field_name ); 102 | } 103 | return $this->data[ $field_name ] = $field_value; 104 | } 105 | } 106 | 107 | /** 108 | * Returns the name of the table 109 | * 110 | * Note that MySQL doesn't allow anything other than alphanumerics, 111 | * underscores, and $, so dashes in the slug are replaced with underscores. 112 | * 113 | * @return string The name of the table 114 | */ 115 | public static function table_name() { 116 | global $wpdb; 117 | 118 | return $wpdb->prefix . 'simplerstatic_' . static::$table_name; 119 | } 120 | 121 | /** 122 | * Used for finding models matching certain criteria 123 | * 124 | * @return Query 125 | */ 126 | public static function query() { 127 | // @phpstan-ignore-next-line 128 | $query = new Query( get_called_class() ); 129 | 130 | return $query; 131 | } 132 | 133 | /** 134 | * Initialize an instance of the class and set its attributes 135 | * 136 | * @param mixed[] $attributes Array of attributes to set for the class 137 | * @return static An instance of the class 138 | */ 139 | public static function initialize( $attributes ) { 140 | // TODO: look at safer option 141 | // @phpstan-ignore-next-line 142 | $obj = new static(); 143 | foreach ( array_keys( static::$columns ) as $column ) { 144 | $obj->data[ $column ] = null; 145 | } 146 | $obj->attributes( $attributes ); 147 | return $obj; 148 | } 149 | 150 | /** 151 | * Set the attributes of the model 152 | * 153 | * @param mixed[] $attributes Array of attributes to set 154 | * @return static An instance of the class 155 | */ 156 | public function attributes( $attributes ) { 157 | foreach ( $attributes as $name => $value ) { 158 | $this->$name = $value; 159 | } 160 | return $this; 161 | } 162 | 163 | /** 164 | * Save the model to the database 165 | * 166 | * If the model is new a record gets created in the database, otherwise the 167 | * existing record gets updated. 168 | * 169 | * @return boolean An instance of the class 170 | */ 171 | public function save() { 172 | global $wpdb; 173 | 174 | // autoset created_at/updated_at upon save 175 | if ( $this->created_at === null ) { 176 | $this->created_at = Util::formatted_datetime(); 177 | } 178 | 179 | $this->updated_at = Util::formatted_datetime(); 180 | 181 | // If we haven't changed anything, don't bother updating the DB, and 182 | // return that saving was successful. 183 | if ( empty( $this->dirty_fields ) ) { 184 | return true; 185 | } else { 186 | // otherwise, create a new array with just the fields we're updating, 187 | // then set the dirty fields back to empty 188 | $fields = array_intersect_key( $this->data, array_flip( $this->dirty_fields ) ); 189 | $this->dirty_fields = []; 190 | } 191 | 192 | if ( $this->exists() ) { 193 | $primary_key = static::$primary_key; 194 | $rows_updated = 195 | $wpdb->update( 196 | self::table_name(), 197 | $fields, 198 | [ $primary_key => $this->$primary_key ] 199 | ); 200 | return $rows_updated !== false; 201 | } else { 202 | $rows_updated = $wpdb->insert( self::table_name(), $fields ); 203 | if ( $rows_updated === false ) { 204 | return false; 205 | } else { 206 | $this->id = $wpdb->insert_id; 207 | return true; 208 | } 209 | } 210 | } 211 | 212 | /** 213 | * Check if the model exists in the database 214 | * 215 | * Technically this is checking whether the model has its primary key set. 216 | * If it is set, we assume the record exists in the database. 217 | * 218 | * @return boolean Does this model exist in the db? 219 | */ 220 | public function exists() { 221 | $primary_key = static::$primary_key; 222 | return $this->$primary_key !== null; 223 | } 224 | 225 | /** 226 | * Create or update the table for the model 227 | * 228 | * Uses the static::$table_name and loops through all of the columns in 229 | * static::$columns and the indexes in static::$indexes to create a SQL 230 | * query for creating the table. 231 | * 232 | * http://wordpress.stackexchange.com/questions/78667/dbdelta-alter-table-syntax 233 | * 234 | * @return void 235 | */ 236 | public static function create_or_update_table() { 237 | global $wpdb; 238 | 239 | $charset_collate = $wpdb->get_charset_collate(); 240 | $sql = 'CREATE TABLE ' . self::table_name() . ' (' . "\n"; 241 | 242 | foreach ( static::$columns as $column_name => $column_definition ) { 243 | $sql .= $column_name . ' ' . $column_definition . ', ' . "\n"; 244 | } 245 | foreach ( static::$indexes as $index ) { 246 | $sql .= $index . ', ' . "\n"; 247 | } 248 | 249 | // remove trailing newline 250 | $sql = rtrim( $sql, "\n" ); 251 | // remove trailing comma 252 | $sql = rtrim( $sql, ', ' ); 253 | $sql .= "\n" . ') ' . "\n" . $charset_collate; 254 | 255 | require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 256 | dbDelta( $sql ); 257 | } 258 | 259 | /** 260 | * Drop the table for the model 261 | * 262 | * @return void 263 | */ 264 | public static function drop_table() { 265 | global $wpdb; 266 | 267 | $wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() ); 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/Options.php: -------------------------------------------------------------------------------- 1 | options = $options; 64 | } 65 | 66 | return self::$instance; 67 | } 68 | 69 | /** 70 | * Return a fresh instance of Options 71 | * 72 | * @return Options 73 | */ 74 | public static function reinstance() { 75 | self::$instance = null; 76 | return self::instance(); 77 | } 78 | 79 | /** 80 | * Updates the option identified by $name with the value provided in $value 81 | * 82 | * @param string $name The option name 83 | * @param mixed $value The option value 84 | * @return Options 85 | */ 86 | public function set( $name, $value ) { 87 | $this->options[ $name ] = $value; 88 | return $this; 89 | } 90 | 91 | /** 92 | * Returns a value of the option identified by $name 93 | * 94 | * @param string $name The option name 95 | * @return mixed|null 96 | */ 97 | public function get( $name ) { 98 | return array_key_exists( $name, $this->options ) ? $this->options[ $name ] : null; 99 | } 100 | 101 | /** 102 | * Destroy an option 103 | * 104 | * @param string $name The option name to destroy 105 | * @return boolean true if the key existed, false if it didn't 106 | */ 107 | public function destroy( $name ) { 108 | if ( array_key_exists( $name, $this->options ) ) { 109 | unset( $this->options[ $name ] ); 110 | return true; 111 | } else { 112 | return false; 113 | } 114 | } 115 | 116 | /** 117 | * Returns all options as an array 118 | * 119 | * @return mixed[] options 120 | */ 121 | public function get_as_array() { 122 | return $this->options; 123 | } 124 | 125 | /** 126 | * Saves the internal options data to the wp_options table 127 | * 128 | * @return boolean 129 | */ 130 | public function save() { 131 | return update_option( Plugin::SLUG, $this->options ); 132 | } 133 | 134 | /** 135 | * Get the current path to the temp static archive directory 136 | * 137 | * @return string The path to the temp static archive directory 138 | */ 139 | public function get_archive_dir() { 140 | return Util::add_trailing_directory_separator( 141 | $this->get( 'temp_files_dir' ) . $this->get( 'archive_name' ) 142 | ); 143 | } 144 | 145 | /** 146 | * Get the destination URL (scheme + host) 147 | * 148 | * @return string The destination URL 149 | */ 150 | public function get_destination_url() { 151 | return $this->get( 'destination_scheme' ) . $this->get( 'destination_host' ); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Page.php: -------------------------------------------------------------------------------- 1 | 'BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT', 85 | 'found_on_id' => 'BIGINT(20) UNSIGNED NULL', 86 | 'url' => 'VARCHAR(255) NOT NULL', 87 | 'redirect_url' => 'TEXT NULL', 88 | 'file_path' => 'VARCHAR(255) NULL', 89 | 'http_status_code' => 'SMALLINT(20) NULL', 90 | 'content_type' => 'VARCHAR(255) NULL', 91 | 'content_hash' => 'BINARY(20) NULL', 92 | 'error_message' => 'VARCHAR(255) NULL', 93 | 'status_message' => 'VARCHAR(255) NULL', 94 | 'last_checked_at' => "DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00'", 95 | 'last_modified_at' => "DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00'", 96 | 'last_transferred_at' => "DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00'", 97 | 'created_at' => "DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00'", 98 | 'updated_at' => "DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00'", 99 | ]; 100 | 101 | /** 102 | * @var string[] 103 | */ 104 | protected static $indexes = [ 105 | 'PRIMARY KEY (id)', 106 | 'KEY url (url)', 107 | 'KEY last_checked_at (last_checked_at)', 108 | 'KEY last_modified_at (last_modified_at)', 109 | 'KEY last_transferred_at (last_transferred_at)', 110 | ]; 111 | 112 | /** @const */ 113 | protected static $primary_key = 'id'; 114 | 115 | /** 116 | * Get the number of pages for each group of status codes, e.g. 1xx, 2xx, 3xx 117 | * 118 | * @return mixed[] Assoc. array of status code to number of pages, e.g. '2' => 183 119 | */ 120 | public static function get_http_status_codes_summary() { 121 | global $wpdb; 122 | 123 | $query = 'SELECT LEFT(http_status_code, 1) AS status, COUNT(*) AS count'; 124 | $query .= ' FROM ' . self::table_name(); 125 | $query .= ' GROUP BY LEFT(http_status_code, 1)'; 126 | $query .= ' ORDER BY status'; 127 | 128 | $rows = $wpdb->get_results( 129 | $query, 130 | ARRAY_A 131 | ); 132 | 133 | $http_codes = [ 134 | '1' => 0, 135 | '2' => 0, 136 | '3' => 0, 137 | '4' => 0, 138 | '5' => 0, 139 | '6' => 0, 140 | '7' => 0, 141 | '8' => 0, 142 | ]; 143 | 144 | foreach ( $rows as $row ) { 145 | $http_codes[ $row['status'] ] = $row['count']; 146 | } 147 | 148 | return $http_codes; 149 | } 150 | 151 | /** 152 | * Return the static page that this page belongs to (if any) 153 | * 154 | * @return Query|null The parent Page 155 | */ 156 | public function parent_static_page() { 157 | return self::query()->find_by( 'id', $this->found_on_id ); 158 | } 159 | 160 | /** 161 | * Check if the hash for the content matches the prior hash for the page 162 | * 163 | * @param string $sha1 The content of the page/file 164 | * @return bool Is the hash a match? 165 | */ 166 | public function is_content_identical( $sha1 ) { 167 | return $sha1 === $this->content_hash; 168 | } 169 | 170 | /** 171 | * Set the hash for the content and update the last_modified_at value 172 | */ 173 | public function set_content_hash( string $sha1 ) : void { 174 | $this->content_hash = $sha1; 175 | $this->last_modified_at = (string) Util::formatted_datetime(); 176 | } 177 | 178 | /** 179 | * Sets or appends an error message 180 | * 181 | * An error indicates that something bad happened when fetching the page, or 182 | * saving the page, or during some other activity related to the page. 183 | */ 184 | public function set_error_message( string $message ) : void { 185 | if ( $this->error_message ) { 186 | $this->error_message = $this->error_message . '; ' . $message; 187 | } else { 188 | $this->error_message = $message; 189 | } 190 | } 191 | 192 | /** 193 | * Sets or appends a status message 194 | * 195 | * A status message is used to indicate things that happened to the page 196 | * that weren't errors, such as not following links or not saving the page. 197 | */ 198 | public function set_status_message( string $message ) : void { 199 | if ( $this->status_message ) { 200 | $this->status_message = $this->status_message . '; ' . $message; 201 | } else { 202 | $this->status_message = $message; 203 | } 204 | } 205 | 206 | public function is_type( string $content_type ) : bool { 207 | return stripos( $this->content_type, $content_type ) !== false; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/Query.php: -------------------------------------------------------------------------------- 1 | model = $model; 49 | } 50 | 51 | /** 52 | * Execute the query and return an array of models 53 | * 54 | * @return mixed[] 55 | */ 56 | public function find() { 57 | global $wpdb; 58 | 59 | $model = $this->model; 60 | $query = $this->compose_select_query(); 61 | 62 | $rows = $wpdb->get_results( 63 | $query, 64 | ARRAY_A 65 | ); 66 | 67 | if ( $rows === null ) { 68 | return []; 69 | } else { 70 | $records = []; 71 | 72 | foreach ( $rows as $row ) { 73 | $records[] = $model::initialize( $row ); 74 | } 75 | 76 | return $records; 77 | } 78 | } 79 | 80 | /** 81 | * First and return the first record matching the conditions 82 | * 83 | * @return mixed An instance of the class, or null 84 | */ 85 | public function first() { 86 | global $wpdb; 87 | 88 | $model = $this->model; 89 | 90 | $this->limit( 1 ); 91 | $query = $this->compose_select_query(); 92 | 93 | $attributes = $wpdb->get_row( 94 | $query, 95 | ARRAY_A 96 | ); 97 | 98 | if ( $attributes === null ) { 99 | return null; 100 | } else { 101 | return $model::initialize( $attributes ); 102 | } 103 | } 104 | 105 | /** 106 | * Find and return the first record matching the column name/value 107 | * 108 | * Example: find_by( 'id', 123 ) 109 | * 110 | * @param string $column_name The name of the column to search on 111 | * @param string $value The value that the column should contain 112 | * @return mixed An instance of the class, or null 113 | */ 114 | public function find_by( $column_name, $value ) { 115 | global $wpdb; 116 | 117 | $model = $this->model; 118 | $this->where( [ $column_name => $value ] ); 119 | 120 | $query = $this->compose_select_query(); 121 | 122 | $attributes = $wpdb->get_row( 123 | $query, 124 | ARRAY_A 125 | ); 126 | 127 | if ( $attributes === null ) { 128 | return null; 129 | } else { 130 | return $model::initialize( $attributes ); 131 | } 132 | } 133 | 134 | /** 135 | * Find or initialize the first record with the given column name/value 136 | * 137 | * Finds the first record with the given column name/value, or initializes 138 | * an instance of the model if one is not found. 139 | * 140 | * @param string $column_name The name of the column to search on 141 | * @param string $value The value that the column should contain 142 | * @return mixed An instance of the class (might not exist in db yet) 143 | */ 144 | public function find_or_initialize_by( $column_name, $value ) { 145 | global $wpdb; 146 | 147 | $model = $this->model; 148 | 149 | $obj = $this->find_by( $column_name, $value ); 150 | if ( ! $obj ) { 151 | $obj = $model::initialize( [ $column_name => $value ] ); 152 | } 153 | 154 | return $obj; 155 | } 156 | 157 | /** 158 | * Find the first record with the given column name/value, or create it 159 | * 160 | * @param string $column_name The name of the column to search on 161 | * @param string $value The value that the column should contain 162 | * @return static An instance of the class (might not exist in db yet) 163 | */ 164 | public function find_or_create_by( $column_name, $value ) { 165 | $obj = $this->find_or_initialize_by( $column_name, $value ); 166 | if ( ! $obj->exists() ) { 167 | $obj->save(); 168 | } 169 | return $obj; 170 | } 171 | 172 | /** 173 | * Update all records to set the column name equal to the value 174 | * 175 | * String: 176 | * A single string, without additional args, is passed as-is to the query. 177 | * update_all( "widget_id = 2" ) 178 | * 179 | * Assoc. array: 180 | * An associative array will use the keys as fields and the values as the 181 | * values to be updated. 182 | * update_all( array( 'widget_id' => 2, 'type' => 'sprocket' ) ) 183 | * 184 | * @param mixed $arg See description 185 | * @return int|null The number of rows updated, or null if failure 186 | * @throws SimplerStaticException 187 | */ 188 | public function update_all( $arg ) { 189 | if ( func_num_args() > 1 ) { 190 | throw new SimplerStaticException( 'Too many arguments passed' ); 191 | } 192 | 193 | global $wpdb; 194 | 195 | $query = $this->compose_update_query( $arg ); 196 | $rows_updated = $wpdb->query( $query ); 197 | 198 | return $rows_updated; 199 | } 200 | 201 | /** 202 | * Delete records matching a where query, replacing ? with $args 203 | * 204 | * @return int|null The number of rows deleted, or null if failure 205 | */ 206 | public function delete_all() { 207 | global $wpdb; 208 | 209 | $query = $this->compose_query( 'DELETE FROM ' ); 210 | $rows_deleted = $wpdb->query( $query ); 211 | 212 | return $rows_deleted; 213 | } 214 | 215 | /** 216 | * Execute the query and return a count of records 217 | * 218 | * @return int|null 219 | */ 220 | public function count() { 221 | global $wpdb; 222 | 223 | $query = $this->compose_select_query( 'COUNT(*)' ); 224 | 225 | return $wpdb->get_var( $query ); 226 | } 227 | 228 | /** 229 | * Set the maximum number of rows to return 230 | * 231 | * @param integer $limit 232 | * @return self 233 | */ 234 | public function limit( $limit ) { 235 | $this->limit = $limit; 236 | return $this; 237 | } 238 | 239 | /** 240 | * Set the number of rows to skip before returning results 241 | * 242 | * @param integer $offset 243 | * @return self 244 | * @throws SimplerStaticException 245 | */ 246 | public function offset( $offset ) { 247 | if ( $this->limit === null ) { 248 | throw new SimplerStaticException( 'Cannot offset without limit' ); 249 | } 250 | 251 | $this->offset = $offset; 252 | return $this; 253 | } 254 | 255 | /** 256 | * Set the ordering for results 257 | * 258 | * @param string $order 259 | * @return self 260 | */ 261 | public function order( $order ) { 262 | $this->order = $order; 263 | return $this; 264 | } 265 | 266 | /** 267 | * Add a where clause to the query 268 | * 269 | * String: 270 | * A single string, without additional args, is passed as-is to the query. 271 | * where( "widget_id = 2" ) 272 | * 273 | * assoc. array: 274 | * An associative array will use the keys as fields and the values as the 275 | * values to be searched for to create a condition. 276 | * where( array( 'widget_id' => 2, 'type' => 'sprocket' ) ) 277 | * 278 | * string + args: 279 | * A string with placeholders '?' and additional args will have the string 280 | * treated as a template and the remaining args inserted into the template 281 | * to create a condition. 282 | * where( 'widget_id > ? AND widget_id < ?', 12, 18 ) 283 | * 284 | * @param mixed $arg See description 285 | * @return self 286 | * @throws SimplerStaticException 287 | */ 288 | public function where( $arg ) { 289 | if ( func_num_args() == 1 ) { 290 | if ( is_array( $arg ) ) { 291 | // add array of conditions to the "where" array 292 | foreach ( $arg as $column_name => $value ) { 293 | $this->where[] = self::where_sql( $column_name, $value ); 294 | } 295 | } elseif ( is_string( $arg ) ) { 296 | // pass the string as-is to our "where" array 297 | $this->where[] = $arg; 298 | } else { 299 | throw new SimplerStaticException( 300 | 'One argument provided and it was not a string or array' 301 | ); 302 | } 303 | } elseif ( func_num_args() > 1 ) { 304 | $where_values = func_get_args(); 305 | $condition = array_shift( $where_values ); 306 | 307 | if ( is_string( $condition ) ) { 308 | // check that the number of args and ?'s matches 309 | if ( substr_count( $condition, '?' ) != count( $where_values ) ) { 310 | throw new SimplerStaticException( 311 | "Number of arguments does not match number of placeholders (?'s)" 312 | ); 313 | } else { 314 | // create a condition to add to the "where" array 315 | foreach ( $where_values as $value ) { 316 | $condition = preg_replace( 317 | '/\?/', 318 | self::escape_and_quote( $value ), 319 | (string) $condition, 320 | 1 321 | ); 322 | } 323 | 324 | $this->where[] = $condition; 325 | } 326 | } else { 327 | throw new SimplerStaticException( 328 | 'Multiple arguments provided but first arg was not a string' 329 | ); 330 | } 331 | } else { 332 | throw new SimplerStaticException( 'No arguments provided' ); 333 | } 334 | 335 | return $this; 336 | } 337 | 338 | /** 339 | * Generate a SQL query for selecting records 340 | * 341 | * @param string $fields Fields to select (null = all records) 342 | * @return string The SQL query for selecting records 343 | */ 344 | private function compose_select_query( $fields = null ) { 345 | $select = ''; 346 | 347 | if ( $fields ) { 348 | $select = $fields; 349 | } else { 350 | $select = '*'; 351 | } 352 | 353 | $statement = "SELECT {$select} FROM "; 354 | 355 | return $this->compose_query( $statement ); 356 | } 357 | 358 | /** 359 | * Generate a SQL query for updating records 360 | * 361 | * String: 362 | * A single string, without additional args, is passed as-is to the query. 363 | * compose_update_query( "widget_id = 2" ) 364 | * 365 | * Assoc. array: 366 | * An associative array will use the keys as fields and the values as the 367 | * values to be updated to create a condition. 368 | * compose_update_query( array( 'widget_id' => 2, 'type' => 'sprocket' ) ) 369 | * 370 | * @param mixed $arg See description 371 | * @throws SimplerStaticException 372 | */ 373 | private function compose_update_query( $arg ) : string { 374 | $values = ' SET '; 375 | 376 | if ( is_array( $arg ) ) { 377 | // add array of conditions to the "where" array 378 | foreach ( $arg as $column_name => $value ) { 379 | $value = self::escape_and_quote( $value ); 380 | $values .= "{$column_name} = $value "; 381 | } 382 | } elseif ( is_string( $arg ) ) { 383 | // pass the string as-is to our "where" array 384 | $values .= $arg . ' '; 385 | } else { 386 | throw new SimplerStaticException( 'Argument provided was not a string or array' ); 387 | } 388 | 389 | return $this->compose_query( 'UPDATE ', $values ); 390 | } 391 | 392 | /** 393 | * Generate a SQL query 394 | * 395 | * @param string $statement SELECT *, UPDATE, etc. 396 | */ 397 | private function compose_query( $statement, string $values = '' ) : string { 398 | $model = $this->model; 399 | $table = ' ' . $model::table_name(); 400 | $where = ''; 401 | $order = ''; 402 | $limit = ''; 403 | $offset = ''; 404 | 405 | foreach ( $this->where as $condition ) { 406 | $where .= ' AND ' . $condition; 407 | } 408 | 409 | if ( $where !== '' ) { 410 | $where = ' WHERE 1=1' . $where; 411 | } 412 | 413 | if ( $this->order ) { 414 | $order = ' ORDER BY ' . $this->order; 415 | } 416 | 417 | if ( $this->limit ) { 418 | $limit = ' LIMIT ' . $this->limit; 419 | } 420 | 421 | if ( $this->offset ) { 422 | $offset = ' OFFSET ' . $this->offset; 423 | } 424 | 425 | $query = "{$statement}{$table}{$values}${where}{$order}{$limit}{$offset}"; 426 | return $query; 427 | } 428 | 429 | /** 430 | * Generate a SQL fragment for use in WHERE x=y 431 | * 432 | * @param string $column_name The name of the column 433 | * @param mixed $value The value for the column 434 | * @return string The SQL fragment to be used in WHERE x=y 435 | */ 436 | private static function where_sql( $column_name, $value ) { 437 | $where_sql = $column_name; 438 | $where_sql .= ( $value === null ) ? ' IS ' : ' = '; 439 | $where_sql .= self::escape_and_quote( $value ); 440 | return $where_sql; 441 | } 442 | 443 | /** 444 | * @param mixed $value 445 | */ 446 | private static function escape_and_quote( $value ) : string { 447 | if ( $value === null ) { 448 | return 'NULL'; 449 | } else { 450 | $value = esc_sql( $value ); 451 | 452 | if ( is_string( $value ) ) { 453 | return "'{$value}'"; 454 | } 455 | } 456 | } 457 | } 458 | -------------------------------------------------------------------------------- /src/Setup_Task.php: -------------------------------------------------------------------------------- 1 | save_status_message( $message ); 23 | 24 | $archive_dir = $this->options->get_archive_dir(); 25 | 26 | // create temp archive directory 27 | if ( ! file_exists( $archive_dir ) ) { 28 | Util::debug_log( 'Creating archive directory: ' . $archive_dir ); 29 | $create_dir = wp_mkdir_p( $archive_dir ); 30 | if ( $create_dir === false ) { 31 | return new \WP_Error( 'cannot_create_archive_dir' ); 32 | } 33 | } 34 | 35 | // TODO: Add a way for the user to perform this, optionally, so that we 36 | // don't need to do it every time. Then enable the two commented-out 37 | // sections below. 38 | Page::query()->delete_all(); 39 | 40 | // clear out any saved error messages on pages 41 | // Page::query() 42 | // ->update_all( 'error_message', null ); 43 | 44 | // delete pages that we can't process 45 | // Page::query() 46 | // ->where( 'http_status_code IS NULL OR http_status_code NOT IN (?)', 47 | // implode( ',', Page::$processable_status_codes ) ) 48 | // ->delete_all(); 49 | 50 | // add origin url and additional urls/files to database 51 | self::add_origin_and_additional_urls_to_db( $this->options->get( 'additional_urls' ) ); 52 | self::add_additional_files_to_db( $this->options->get( 'additional_files' ) ); 53 | 54 | return true; 55 | } 56 | 57 | /** 58 | * Ensure the Origin URL and user-specified Additional URLs are in the DB 59 | * 60 | * @return void 61 | */ 62 | public static function add_origin_and_additional_urls_to_db( string $additional_urls ) { 63 | $origin_url = trailingslashit( Util::origin_url() ); 64 | Util::debug_log( 'Adding origin URL to queue: ' . $origin_url ); 65 | $static_page = Page::query()->find_or_initialize_by( 'url', $origin_url ); 66 | $static_page->set_status_message( __( 'Origin URL', 'simplerstatic' ) ); 67 | // setting to 0 for "not found anywhere" since it's either the origin 68 | // or something the user specified 69 | $static_page->found_on_id = 0; 70 | $static_page->save(); 71 | 72 | $urls = array_unique( Util::string_to_array( $additional_urls ) ); 73 | foreach ( $urls as $url ) { 74 | if ( Util::is_local_url( $url ) ) { 75 | Util::debug_log( 'Adding additional URL to queue: ' . $url ); 76 | $static_page = Page::query()->find_or_initialize_by( 'url', $url ); 77 | $static_page->set_status_message( __( 'Additional URL', 'simplerstatic' ) ); 78 | $static_page->found_on_id = 0; 79 | $static_page->save(); 80 | } 81 | } 82 | } 83 | 84 | /** 85 | * Convert Additional Files/Directories to URLs and add them to the database 86 | * 87 | * @return void 88 | */ 89 | public static function add_additional_files_to_db( string $additional_files ) { 90 | // Convert additional files to URLs and add to queue 91 | foreach ( Util::string_to_array( $additional_files ) as $item ) { 92 | 93 | // If item is a file, convert to url and insert into database. 94 | // If item is a directory, recursively iterate and grab all files, 95 | // and for each file, convert to url and insert into database. 96 | if ( file_exists( $item ) ) { 97 | if ( is_file( $item ) ) { 98 | $url = self::convert_path_to_url( $item ); 99 | Util::debug_log( 'File ' . $item . ' exists; adding to queue as: ' . $url ); 100 | $static_page = Page::query() 101 | ->find_or_create_by( 'url', $url ); 102 | $static_page->set_status_message( __( 'Additional File', 'simplerstatic' ) ); 103 | // setting found_on_id to 0 since this was user-specified 104 | $static_page->found_on_id = 0; 105 | $static_page->save(); 106 | } else { 107 | Util::debug_log( 'Adding files from directory: ' . $item ); 108 | $iterator = new RecursiveIteratorIterator( 109 | new RecursiveDirectoryIterator( 110 | $item, 111 | RecursiveDirectoryIterator::SKIP_DOTS 112 | ) 113 | ); 114 | 115 | foreach ( $iterator as $file_name => $file_object ) { 116 | $url = self::convert_path_to_url( $file_name ); 117 | Util::debug_log( 'Adding file ' . $file_name . ' to queue as: ' . $url ); 118 | $static_page = Page::query()->find_or_initialize_by( 'url', $url ); 119 | $static_page->set_status_message( __( 'Additional Dir', 'simplerstatic' ) ); 120 | $static_page->found_on_id = 0; 121 | $static_page->save(); 122 | } 123 | } 124 | } else { 125 | Util::debug_log( "File doesn't exist: " . $item ); 126 | } 127 | } 128 | } 129 | 130 | /** 131 | * Convert a directory path into a valid WordPress URL 132 | * 133 | * @param string $path The path to a directory or a file 134 | * @return string The WordPress URL for the given path 135 | */ 136 | private static function convert_path_to_url( $path ) { 137 | $url = $path; 138 | if ( stripos( $path, WP_PLUGIN_DIR ) === 0 ) { 139 | $url = str_replace( WP_PLUGIN_DIR, WP_PLUGIN_URL, $path ); 140 | } elseif ( stripos( $path, WP_CONTENT_DIR ) === 0 ) { 141 | $url = str_replace( WP_CONTENT_DIR, WP_CONTENT_URL, $path ); 142 | } elseif ( stripos( $path, get_home_path() ) === 0 ) { 143 | $url = str_replace( untrailingslashit( get_home_path() ), Util::origin_url(), $path ); 144 | } 145 | return $url; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/SimplerStaticException.php: -------------------------------------------------------------------------------- 1 | false, 30 | 'update' => false, 31 | 'insert' => false, 32 | 'delete' => false, 33 | 'alter' => false, 34 | 'create' => false, 35 | 'drop' => false, 36 | ]; 37 | 38 | /** 39 | * Disable usage of "new" 40 | * 41 | * @return void 42 | */ 43 | protected function __construct() {} 44 | 45 | /** 46 | * Disable cloning of the class 47 | * 48 | * @return void 49 | */ 50 | protected function __clone() {} 51 | 52 | /** 53 | * Disable unserializing of the class 54 | * 55 | * @return void 56 | */ 57 | public function __wakeup() {} 58 | 59 | /** 60 | * Return an instance of Sql_Permissions 61 | * 62 | * @return Sql_Permissions 63 | */ 64 | public static function instance() { 65 | if ( null === self::$instance ) { 66 | self::$instance = new self(); 67 | 68 | global $wpdb; 69 | $rows = $wpdb->get_results( 'SHOW GRANTS FOR current_user()', ARRAY_N ); 70 | 71 | // Loop through all of the grants and set permissions to true where 72 | // we're able to find them. 73 | foreach ( $rows as $row ) { 74 | // Find the database name 75 | preg_match( '/GRANT (.+) ON (.+) TO/', $row[0], $matches ); 76 | // Removing backticks and backslashes for easier matching 77 | $db_name = preg_replace( '/[\\\`]/', '', $matches[2] ); 78 | 79 | if ( substr( $db_name, -3 ) == '%.*' ) { 80 | // Check for a wildcard match on the database 81 | $db_name = substr( $db_name, 0, -3 ); 82 | $db_name_match = ( stripos( $wpdb->dbname, $db_name ) === 0 ); 83 | } else { 84 | // Check for matches for all dbs (*.*) or this specific WP db 85 | $db_name_match = in_array( $db_name, [ '*.*', $wpdb->dbname . '.*' ] ); 86 | } 87 | 88 | if ( $db_name_match ) { 89 | foreach ( explode( ',', $matches[1] ) as $permission ) { 90 | $permission = str_replace( ' ', '_', trim( strtolower( $permission ) ) ); 91 | if ( $permission === 'all_privileges' ) { 92 | foreach ( self::$instance->permissions as $key => $value ) { 93 | self::$instance->permissions[ $key ] = true; 94 | } 95 | } 96 | self::$instance->permissions[ $permission ] = true; 97 | } 98 | } 99 | } 100 | } 101 | 102 | return self::$instance; 103 | } 104 | 105 | /** 106 | * Check if the MySQL user is able to perform the provided permission 107 | */ 108 | public function can( string $permission ) : bool { 109 | return ( 110 | isset( $this->permissions[ $permission ] ) && 111 | $this->permissions[ $permission ] === true 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Task.php: -------------------------------------------------------------------------------- 1 | options = Options::instance(); 25 | } 26 | 27 | /** 28 | * Add a message to the array of status messages for the job 29 | * 30 | * Providing a unique key for the message is optional. If one isn't 31 | * provided, the state_name will be used. Using the same key more than once 32 | * will overwrite previous messages. 33 | * 34 | * @param string $message Message to display about the status of the job 35 | * @param string $key Unique key for the message 36 | * @return void 37 | */ 38 | protected function save_status_message( $message, $key = null ) { 39 | $task_name = $key ? $key : static::$task_name; 40 | $messages = $this->options->get( 'archive_status_messages' ); 41 | Util::debug_log( 'Status message: [' . $task_name . '] ' . $message ); 42 | 43 | $messages = Util::add_archive_status_message( $messages, $task_name, $message ); 44 | 45 | $this->options 46 | ->set( 'archive_status_messages', $messages ) 47 | ->save(); 48 | } 49 | 50 | /** 51 | * Override this method to perform the task action. 52 | * 53 | * @return mixed true if done, false if not done, WP_Error if error 54 | */ 55 | abstract public function perform(); 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/Transfer_Files_Locally_Task.php: -------------------------------------------------------------------------------- 1 | options->get( 'local_dir' ); 19 | 20 | list( $pages_processed, $total_pages ) = $this->copy_static_files( $local_dir ); 21 | 22 | if ( $pages_processed !== 0 ) { 23 | $message = sprintf( 'Copied %1$d of %2$d files', $pages_processed, $total_pages ); 24 | $this->save_status_message( $message ); 25 | } 26 | 27 | if ( $pages_processed >= $total_pages ) { 28 | if ( $this->options->get( 'destination_url_type' ) == 'absolute' ) { 29 | $destination_url = trailingslashit( $this->options->get_destination_url() ); 30 | $message = 'Destination URL: ' . $destination_url . ''; 32 | $this->save_status_message( $message, 'destination_url' ); 33 | } 34 | } 35 | 36 | // return true when done (no more pages) 37 | return $pages_processed >= $total_pages; 38 | 39 | } 40 | 41 | /** 42 | * Copy temporary static files to a local directory 43 | * 44 | * @param string $destination_dir The directory to put the files 45 | * @return mixed[] (# pages processed, # pages remaining) 46 | */ 47 | public function copy_static_files( $destination_dir ) { 48 | $batch_size = 100; 49 | 50 | $archive_dir = $this->options->get_archive_dir(); 51 | $archive_start_time = $this->options->get( 'archive_start_time' ); 52 | 53 | // TODO: also check for recent modification time 54 | // last_modified_at > ? AND 55 | $static_pages = Page::query() 56 | ->where( 'file_path IS NOT NULL' ) 57 | ->where( "file_path != ''" ) 58 | ->where( 59 | '( last_transferred_at < ? OR last_transferred_at IS NULL )', 60 | $archive_start_time 61 | ) 62 | ->limit( $batch_size ) 63 | ->find(); 64 | $pages_remaining = count( $static_pages ); 65 | $total_pages = Page::query() 66 | ->where( 'file_path IS NOT NULL' ) 67 | ->where( "file_path != ''" ) 68 | ->count(); 69 | $pages_processed = $total_pages - $pages_remaining; 70 | Util::debug_log( 71 | 'Total pages: ' . $total_pages . '; Pages remaining: ' . $pages_remaining 72 | ); 73 | 74 | while ( $static_page = array_shift( $static_pages ) ) { 75 | $path_info = Util::url_path_info( $static_page->file_path ); 76 | $path = $destination_dir . $path_info['dirname']; 77 | $create_dir = wp_mkdir_p( $path ); 78 | if ( $create_dir === false ) { 79 | Util::debug_log( 80 | 'Cannot create directory: ' . $destination_dir . $path_info['dirname'] 81 | ); 82 | $static_page->set_error_message( 'Unable to create destination directory' ); 83 | } else { 84 | chmod( $path, 0755 ); 85 | $origin_file_path = $archive_dir . $static_page->file_path; 86 | $destination_file_path = $destination_dir . $static_page->file_path; 87 | 88 | // check that destination file doesn't exist OR exists but is writeable 89 | if ( 90 | ! file_exists( $destination_file_path ) || 91 | is_writable( $destination_file_path ) 92 | ) { 93 | $copy = copy( $origin_file_path, $destination_file_path ); 94 | if ( $copy === false ) { 95 | Util::debug_log( 96 | "Cannot copy $origin_file_path to $destination_file_path" 97 | ); 98 | $static_page->set_error_message( 'Unable to copy file to destination' ); 99 | } 100 | } else { 101 | Util::debug_log( 'File exists and is unwriteable: ' . $destination_file_path ); 102 | $static_page->set_error_message( 'Destination file exists and is unwriteable' ); 103 | } 104 | } 105 | 106 | $static_page->last_transferred_at = Util::formatted_datetime(); 107 | $static_page->save(); 108 | } 109 | 110 | return [ $pages_processed, $total_pages ]; 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /src/Upgrade_Handler.php: -------------------------------------------------------------------------------- 1 | Util::origin_scheme(), 60 | 'destination_host' => Util::origin_host(), 61 | 'temp_files_dir' => 62 | trailingslashit( plugin_dir_path( dirname( __FILE__ ) ) . 'static-files' ), 63 | 'additional_urls' => '', 64 | 'additional_files' => '', 65 | 'urls_to_exclude' => [], 66 | 'delivery_method' => 'zip', 67 | 'local_dir' => '', 68 | 'delete_temp_files' => '1', 69 | 'relative_path' => '', 70 | 'destination_url_type' => 'relative', 71 | 'archive_status_messages' => [], 72 | 'archive_name' => null, 73 | 'archive_start_time' => null, 74 | 'archive_end_time' => null, 75 | 'debugging_mode' => '0', 76 | 'http_basic_auth_digest' => null, 77 | ]; 78 | 79 | $save_changes = false; 80 | $version = self::$options->get( 'version' ); 81 | 82 | // Never installed or options key changed 83 | if ( null === $version ) { 84 | $save_changes = true; 85 | 86 | // checking for legacy options key 87 | $old_ss_options = get_option( 'simplerstatic' ); 88 | 89 | if ( $old_ss_options ) { // options key changed 90 | update_option( 'simplerstatic', $old_ss_options ); 91 | delete_option( 'simplerstatic' ); 92 | 93 | // update Options again to pull in updated data 94 | self::$options = new Options(); 95 | } 96 | } 97 | 98 | // sync the database on any install/upgrade/downgrade 99 | if ( version_compare( $version, Plugin::VERSION, '!=' ) ) { 100 | $save_changes = true; 101 | 102 | Page::create_or_update_table(); 103 | self::set_default_options(); 104 | 105 | // perform migrations if our saved version # is older than 106 | // the current version 107 | if ( version_compare( $version, Plugin::VERSION, '<' ) ) { 108 | 109 | if ( version_compare( $version, '1.4.0', '<' ) ) { 110 | // check for, and add, the WP emoji url if it's missing 111 | $emoji_url = includes_url( 'js/wp-emoji-release.min.js' ); 112 | $additional_urls = self::$options->get( 'additional_urls' ); 113 | $urls_array = Util::string_to_array( $additional_urls ); 114 | 115 | if ( ! in_array( $emoji_url, $urls_array ) ) { 116 | $additional_urls = $additional_urls . "\n" . $emoji_url; 117 | self::$options->set( 'additional_urls', $additional_urls ); 118 | } 119 | } 120 | 121 | if ( version_compare( $version, '1.7.0', '<' ) ) { 122 | $scheme = self::$options->get( 'destination_scheme' ); 123 | if ( strpos( $scheme, '://' ) === false ) { 124 | $scheme = $scheme . '://'; 125 | self::$options->set( 'destination_scheme', $scheme ); 126 | } 127 | 128 | $host = self::$options->get( 'destination_host' ); 129 | if ( $host == Util::origin_host() ) { 130 | self::$options->set( 'destination_url_type', 'relative' ); 131 | } else { 132 | self::$options->set( 'destination_url_type', 'absolute' ); 133 | } 134 | } 135 | 136 | if ( version_compare( $version, '1.7.1', '<' ) ) { 137 | // check for, and add, the WP uploads dir if it's missing 138 | $upload_dir = wp_upload_dir(); 139 | if ( isset( $upload_dir['basedir'] ) ) { 140 | $upload_dir = trailingslashit( $upload_dir['basedir'] ); 141 | 142 | $additional_files = self::$options->get( 'additional_files' ); 143 | $files_array = Util::string_to_array( $additional_files ); 144 | 145 | if ( ! in_array( $upload_dir, $files_array ) ) { 146 | $additional_files = $additional_files . "\n" . $upload_dir; 147 | self::$options->set( 'additional_files', $additional_files ); 148 | } 149 | } 150 | } 151 | 152 | // setting the temp dir back to the one within /simplerstatic/ 153 | if ( version_compare( $version, '2.0.4', '<' ) ) { 154 | $old_tmp_dir = 155 | trailingslashit( trailingslashit( get_temp_dir() ) . 'static-files' ); 156 | if ( self::$options->get( 'temp_files_dir' ) === $old_tmp_dir ) { 157 | self::$options->set( 158 | 'temp_files_dir', 159 | self::$default_options['temp_files_dir'] 160 | ); 161 | } 162 | } 163 | } 164 | 165 | self::remove_old_options(); 166 | } 167 | 168 | if ( $save_changes ) { 169 | // update the version and save options 170 | self::$options 171 | ->set( 'version', Plugin::VERSION ) 172 | ->save(); 173 | } 174 | } 175 | 176 | /** 177 | * Add default options where they don't exist 178 | * 179 | * @return void 180 | */ 181 | protected static function set_default_options() { 182 | foreach ( self::$default_options as $option_key => $option_value ) { 183 | if ( self::$options->get( $option_key ) === null ) { 184 | self::$options->set( $option_key, $option_value ); 185 | } 186 | } 187 | } 188 | 189 | /** 190 | * Remove any unused (old) options 191 | * 192 | * @return void 193 | */ 194 | protected static function remove_old_options() { 195 | $all_options = self::$options->get_as_array(); 196 | 197 | foreach ( $all_options as $option_key => $option_value ) { 198 | if ( ! array_key_exists( $option_key, self::$default_options ) ) { 199 | self::$options->destroy( $option_key ); 200 | } 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/Url_Extractor.php: -------------------------------------------------------------------------------- 1 | [ 'href', 'urn' ], 33 | 'base' => [ 'href' ], 34 | 'form' => [ 'action', 'data' ], 35 | 'img' => [ 'src', 'usemap', 'longdesc', 'dynsrc', 'lowsrc', 'srcset' ], 36 | 'amp-img' => [ 'src', 'srcset' ], 37 | 'link' => [ 'href' ], 38 | 39 | 'applet' => [ 'code', 'codebase', 'archive', 'object' ], 40 | 'area' => [ 'href' ], 41 | 'body' => [ 'background', 'credits', 'instructions', 'logo' ], 42 | 'input' => [ 'src', 'usemap', 'dynsrc', 'lowsrc', 'action', 'formaction' ], 43 | 44 | 'blockquote' => [ 'cite' ], 45 | 'del' => [ 'cite' ], 46 | 'frame' => [ 'longdesc', 'src' ], 47 | 'head' => [ 'profile' ], 48 | 'iframe' => [ 'longdesc', 'src' ], 49 | 'ins' => [ 'cite' ], 50 | 'object' => [ 'archive', 'classid', 'codebase', 'data', 'usemap' ], 51 | 'q' => [ 'cite' ], 52 | 'script' => [ 'src' ], 53 | 54 | 'audio' => [ 'src' ], 55 | 'command' => [ 'icon' ], 56 | 'embed' => [ 'src', 'code', 'pluginspage' ], 57 | 'event-source' => [ 'src' ], 58 | 'html' => [ 'manifest', 'background', 'xmlns' ], 59 | 'source' => [ 'src' ], 60 | 'video' => [ 'src', 'poster' ], 61 | 62 | 'bgsound' => [ 'src' ], 63 | 'div' => [ 'href', 'src' ], 64 | 'ilayer' => [ 'src' ], 65 | 'table' => [ 'background' ], 66 | 'td' => [ 'background' ], 67 | 'th' => [ 'background' ], 68 | 'layer' => [ 'src' ], 69 | 'xml' => [ 'src' ], 70 | 71 | 'button' => [ 'action', 'formaction' ], 72 | 'datalist' => [ 'data' ], 73 | 'select' => [ 'data' ], 74 | 75 | 'access' => [ 'path' ], 76 | 'card' => [ 'onenterforward', 'onenterbackward', 'ontimer' ], 77 | 'go' => [ 'href' ], 78 | 'option' => [ 'onpick' ], 79 | 'template' => [ 'onenterforward', 'onenterbackward', 'ontimer' ], 80 | 'wml' => [ 'xmlns' ], 81 | ]; 82 | 83 | // /** @const */ 84 | // protected static $match_metas = array( 85 | // 'content-base', 86 | // 'content-location', 87 | // 'referer', 88 | // 'location', 89 | // 'refresh', 90 | // ); 91 | 92 | /** 93 | * The static page to extract URLs from 94 | * 95 | * @var Page 96 | */ 97 | protected $static_page; 98 | 99 | /** 100 | * An instance of the options structure containing all options for this plugin 101 | * 102 | * @var Options 103 | */ 104 | protected $options = null; 105 | 106 | /** 107 | * The url of the site 108 | * 109 | * @var mixed[] 110 | */ 111 | protected $extracted_urls = []; 112 | 113 | public function __construct( Page $static_page ) { 114 | $this->static_page = $static_page; 115 | $this->options = Options::instance(); 116 | } 117 | 118 | /** 119 | * Fetch the content from our file 120 | * 121 | * @return string 122 | */ 123 | public function get_body() { 124 | // Setting the stream context to prevent an issue where non-latin 125 | // characters get converted to html codes like #1234; inappropriately 126 | // http://stackoverflow.com/questions/5600371/file-get-contents-converts-utf-8-to-iso-8859-1 127 | $opts = [ 128 | 'http' => [ 129 | 'header' => 'Accept-Charset: UTF-8', 130 | ], 131 | ]; 132 | $context = stream_context_create( $opts ); 133 | $path = $this->options->get_archive_dir() . $this->static_page->file_path; 134 | 135 | return (string) file_get_contents( $path, false, $context ); 136 | } 137 | 138 | /** 139 | * Save a string back to our file (e.g. after having updated URLs) 140 | * 141 | * @param string $content 142 | * @return int|false 143 | */ 144 | public function save_body( $content ) { 145 | return file_put_contents( 146 | $this->options->get_archive_dir() . $this->static_page->file_path, 147 | $content 148 | ); 149 | } 150 | 151 | /** 152 | * Extracts URLs from the static_page and update them based on the dest. type 153 | * 154 | * Returns a list of unique URLs from the body of the static_page. It only 155 | * extracts URLs from the same domain, either absolute urls or relative urls 156 | * that are then converted to absolute urls. 157 | * 158 | * Note that no validation is performed on whether the URLs would actually 159 | * return a 200/OK response. 160 | * 161 | * @return mixed[] 162 | */ 163 | public function extract_and_update_urls() { 164 | if ( $this->static_page->is_type( 'html' ) ) { 165 | $this->save_body( $this->extract_and_replace_urls_in_html() ); 166 | } 167 | 168 | if ( $this->static_page->is_type( 'css' ) ) { 169 | $this->save_body( $this->extract_and_replace_urls_in_css( $this->get_body() ) ); 170 | } 171 | 172 | if ( $this->static_page->is_type( 'xml' ) ) { 173 | $this->save_body( $this->extract_and_replace_urls_in_xml() ); 174 | } 175 | 176 | // failsafe URL replacement 177 | if ( 178 | $this->static_page->is_type( 'html' ) || 179 | $this->static_page->is_type( 'css' ) || 180 | $this->static_page->is_type( 'xml' ) 181 | ) { 182 | $this->replace_urls(); 183 | } 184 | 185 | return array_unique( $this->extracted_urls ); 186 | } 187 | 188 | /** 189 | * Replaces origin URL with destination URL in response body 190 | * 191 | * This is a function of last resort for URL replacement. Ideally it was 192 | * already done in one of the extract_and_replace_urls_in_x functions. 193 | * 194 | * This catches instances of WordPress URLs and replaces them with the 195 | * destinaton_url. This generally works fine for absolute and relative URL 196 | * generation. It'll produce sub-optimal results for offline URLs, in that 197 | * it's only replacing the host and not adjusting the path according to the 198 | * current page. The point of this is more to remove any traces of the 199 | * WordPress URL than anything else. 200 | * 201 | * @return void 202 | */ 203 | public function replace_urls() { 204 | /* 205 | TODO: 206 | Can we get it to work with offline URLs via preg_replace_callback 207 | + convert_url? To do that we'd need to grab the entire URL. Ideally 208 | that would also work with escaped URLs / inside of JavaScript. And 209 | even more ideally, we'd only have a single preg_replace. 210 | */ 211 | 212 | $destination_url = $this->options->get_destination_url(); 213 | $response_body = $this->get_body(); 214 | 215 | // replace any instance of the origin url, whether it starts with https://, http://, or // 216 | $response_body = preg_replace( 217 | '/(https?:)?\/\/' . addcslashes( Util::origin_host(), '/' ) . '/i', 218 | $destination_url, 219 | $response_body 220 | ); 221 | // replace wp_json_encode'd urls, as used by WP's `concatemoji` 222 | // e.g. {"concatemoji":"http:\/\/w.org\/wp-includes\/js\/wp-emoji-release.min.js?ver=4.6.1"} 223 | $response_body = str_replace( 224 | addcslashes( Util::origin_url(), '/' ), 225 | addcslashes( $destination_url, '/' ), 226 | (string) $response_body 227 | ); 228 | // replace encoded URLs, as found in query params 229 | // e.g. http://w.org/wp-json/oembed/1.0/embed?url=http%3A%2F%2Fexample%2Fcurrent%2Fpage%2F" 230 | $response_body = preg_replace( 231 | '/(https?%3A)?%2F%2F' . addcslashes( urlencode( Util::origin_host() ), '.' ) . '/i', 232 | urlencode( $destination_url ), 233 | $response_body 234 | ); 235 | 236 | $this->save_body( (string) $response_body ); 237 | } 238 | 239 | /** 240 | * Extract URLs and convert URLs to absolute URLs for each tag 241 | * 242 | * The tag is passed by reference, so it's updated directly and nothing is 243 | * returned from this function. 244 | * 245 | * @param mixed $tag dom node 246 | * @param string $tag_name name of the tag 247 | * @param mixed $attributes array of attribute notes 248 | * @return void 249 | */ 250 | private function extract_urls_and_update_tag( &$tag, $tag_name, $attributes ) { 251 | if ( isset( $tag->style ) ) { 252 | $updated_css = $this->extract_and_replace_urls_in_css( $tag->style ); 253 | $tag->style = $updated_css; 254 | } 255 | 256 | foreach ( $attributes as $attribute_name ) { 257 | if ( isset( $tag->$attribute_name ) ) { 258 | $extracted_urls = []; 259 | $attribute_value = $tag->$attribute_name; 260 | 261 | Util::debug_log( "attribute value $attribute_value" ); 262 | // srcset is a fair bit different from most html 263 | // attributes, so it gets it's own processsing 264 | if ( $attribute_name === 'srcset' ) { 265 | $extracted_urls = $this->extract_urls_from_srcset( $attribute_value ); 266 | } else { 267 | $extracted_urls[] = $attribute_value; 268 | } 269 | 270 | foreach ( $extracted_urls as $extracted_url ) { 271 | if ( $extracted_url !== '' ) { 272 | $updated_extracted_url = $this->add_to_extracted_urls( $extracted_url ); 273 | $attribute_value = 274 | str_replace( $extracted_url, $updated_extracted_url, $attribute_value ); 275 | } 276 | } 277 | $tag->$attribute_name = $attribute_value; 278 | } 279 | } 280 | } 281 | 282 | /** 283 | * Loop through elements of interest in the DOM to pull out URLs 284 | * 285 | * There are specific html tags and -- more precisely -- attributes that 286 | * we're looking for. We loop through tags with attributes we care about, 287 | * which the attributes for URLs, extract and update any URLs we find, and 288 | * then return the updated HTML. 289 | * 290 | * @return string The HTML with all URLs made absolute 291 | */ 292 | private function extract_and_replace_urls_in_html() { 293 | $html_string = $this->get_body(); 294 | 295 | $html_web = new HtmlDocument(); 296 | 297 | $dom = $html_web->load( 298 | $html_string 299 | ); 300 | 301 | // return the original html string if dom is blank or boolean (unparseable) 302 | // quick test for processable content 303 | if ( ! $dom ) { 304 | return $html_string; 305 | } else { 306 | // handle tags with attributes 307 | foreach ( self::$match_tags as $tag_name => $attributes ) { 308 | 309 | $tags = $dom->find( $tag_name ); 310 | 311 | foreach ( $tags as $tag ) { 312 | $this->extract_urls_and_update_tag( $tag, $tag_name, $attributes ); 313 | } 314 | } 315 | 316 | // handle 'style' tag differently, since we need to parse the content 317 | $tags = $dom->find( 'style' ); 318 | 319 | foreach ( $tags as $tag ) { 320 | $updated_css = $this->extract_and_replace_urls_in_css( $tag->innertext ); 321 | $tag->innertext = $updated_css; 322 | } 323 | 324 | return $dom->save(); 325 | } 326 | } 327 | 328 | /** 329 | * Extract URLs from the srcset attribute 330 | * 331 | * @param string $srcset Value of the srcset attribute 332 | * @return mixed[] Array of extracted URLs 333 | */ 334 | private function extract_urls_from_srcset( $srcset ) { 335 | $extracted_urls = []; 336 | 337 | foreach ( explode( ',', $srcset ) as $url_and_descriptor ) { 338 | // remove the (optional) descriptor 339 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-srcset 340 | $extracted_urls[] = 341 | trim( (string) preg_replace( '/[\d\.]+[xw]\s*$/', '', $url_and_descriptor ) ); 342 | } 343 | 344 | return $extracted_urls; 345 | } 346 | 347 | /** 348 | * Use regex to extract URLs on CSS pages 349 | * 350 | * URLs in CSS follow three basic patterns: 351 | * - @import "common.css" screen, projection; 352 | * - @import url("fineprint.css") print; 353 | * - background-image: url(image.png); 354 | * 355 | * URLs are either contained within url(), part of an @import statement, 356 | * or both. 357 | * 358 | * @param string $text The CSS to extract URLs from 359 | * @return string The CSS with all URLs converted 360 | */ 361 | private function extract_and_replace_urls_in_css( $text ) { 362 | $patterns = [ 363 | "/url\(\s*[\"']?([^)\"']+)/", // url() 364 | "/@import\s+[\"']([^\"']+)/", 365 | ]; // @import w/o url() 366 | 367 | foreach ( $patterns as $pattern ) { 368 | $text = preg_replace_callback( $pattern, [ $this, 'css_matches' ], (string) $text ); 369 | } 370 | 371 | return (string) $text; 372 | } 373 | 374 | /** 375 | * callback function for preg_replace in extract_and_replace_urls_in_css 376 | * 377 | * Takes the match, extracts the URL, adds it to the list of URLs, converts 378 | * the URL to a destination URL. 379 | * 380 | * @param mixed[] $matches Array of preg_replace matches 381 | * @return string An updated string for the text that was originally matched 382 | */ 383 | private function css_matches( $matches ) { 384 | $full_match = $matches[0]; 385 | $extracted_url = $matches[1]; 386 | 387 | if ( isset( $extracted_url ) && $extracted_url !== '' ) { 388 | $updated_extracted_url = $this->add_to_extracted_urls( $extracted_url ); 389 | $full_match = str_ireplace( $extracted_url, $updated_extracted_url, $full_match ); 390 | } 391 | 392 | return $full_match; 393 | } 394 | 395 | /** 396 | * Use regex to extract URLs from XML docs (e.g. /feed/) 397 | * 398 | * @return string The XML with all of the URLs converted 399 | */ 400 | private function extract_and_replace_urls_in_xml() { 401 | $xml_string = $this->get_body(); 402 | // match anything starting with http/s plus all following characters 403 | // except: [space] " ' < 404 | $pattern = "/https?:\/\/[^\s\"'<]+/"; 405 | $text = preg_replace_callback( $pattern, [ $this, 'xml_matches' ], $xml_string ); 406 | 407 | return (string) $text; 408 | } 409 | 410 | /** 411 | * Callback function for preg_replace in extract_and_replace_urls_in_xml 412 | * 413 | * Takes the match, adds it to the list of URLs, converts the URL to a 414 | * destination URL. 415 | * 416 | * @param mixed[] $matches Array of regex matches found in the XML doc 417 | * @return string The extracted, converted URL 418 | */ 419 | private function xml_matches( $matches ) { 420 | $extracted_url = $matches[0]; 421 | 422 | if ( isset( $extracted_url ) && $extracted_url !== '' ) { 423 | $updated_extracted_url = $this->add_to_extracted_urls( $extracted_url ); 424 | 425 | return $updated_extracted_url; 426 | } 427 | 428 | return $extracted_url; 429 | } 430 | 431 | /** 432 | * Add a URL to the extracted URLs array and convert to absolute/relative/offline 433 | * 434 | * URLs are first converted to absolute URLs. Then they're checked to see if 435 | * they are local URLs; if they are, they're added to the extracted URLs 436 | * queue. 437 | * 438 | * If the destination URL type requested was absolute, the WordPress scheme/ 439 | * host is swapped for the destination scheme/host. If the destination URL 440 | * type is relative/offline, the URL is converted to that format. Then the 441 | * URL is returned. 442 | * 443 | * @param string $extracted_url The URL that should be added to the list of extracted URLs 444 | * @return string The URL, converted to an absolute/relative/offline URL 445 | */ 446 | private function add_to_extracted_urls( string $extracted_url ) : string { 447 | $url = Util::relative_to_absolute_url( $extracted_url, $this->static_page->url ); 448 | 449 | if ( $url && Util::is_local_url( $url ) ) { 450 | // add to extracted urls queue 451 | $this->extracted_urls[] = Util::remove_params_and_fragment( $url ); 452 | 453 | $url = $this->convert_url( $url ); 454 | } 455 | 456 | return (string) $url; 457 | } 458 | 459 | /** 460 | * Convert URL to absolute URL at desired host or to a relative or offline URL 461 | * 462 | * @param string $url Absolute URL to convert 463 | * @return string Converted URL 464 | */ 465 | private function convert_url( $url ) { 466 | if ( $this->options->get( 'destination_url_type' ) == 'absolute' ) { 467 | $url = $this->convert_absolute_url( $url ); 468 | } elseif ( $this->options->get( 'destination_url_type' ) == 'relative' ) { 469 | $url = $this->convert_relative_url( $url ); 470 | } elseif ( $this->options->get( 'destination_url_type' ) == 'offline' ) { 471 | $url = $this->convert_offline_url( $url ); 472 | } 473 | 474 | return $url; 475 | } 476 | 477 | /** 478 | * Convert a WordPress URL to a URL at the destination scheme/host 479 | * 480 | * @param string $url Absolute URL to convert 481 | * @return string URL at destination scheme/host 482 | */ 483 | private function convert_absolute_url( $url ) { 484 | $destination_url = $this->options->get_destination_url(); 485 | $url = Util::strip_protocol_from_url( $url ); 486 | $url = str_replace( Util::origin_host(), $destination_url, (string) $url ); 487 | 488 | return $url; 489 | } 490 | 491 | /** 492 | * Convert a WordPress URL to a relative path 493 | * 494 | * @param string $url Absolute URL to convert 495 | * @return string Relative path for the URL 496 | */ 497 | private function convert_relative_url( $url ) { 498 | $url = Util::get_path_from_local_url( $url ); 499 | $url = $this->options->get( 'relative_path' ) . $url; 500 | 501 | return $url; 502 | } 503 | 504 | /** 505 | * Convert a WordPress URL to a path for offline usage 506 | * 507 | * This function compares current page's URL to the provided URL and 508 | * creates a path for getting from one page to the other. It also attaches 509 | * /index.html onto the end of any path that isn't a file, before any 510 | * fragments or params. 511 | * 512 | * Example: 513 | * static_page->url: http://static-site.dev/2013/01/11/page-a/ 514 | * $url: http://static-site.dev/2013/01/10/page-b/ 515 | * path: ./../../10/page-b/index.html 516 | * 517 | * @param string $url Absolute URL to convert 518 | * @return string Converted path 519 | */ 520 | private function convert_offline_url( $url ) { 521 | // remove the scheme/host from the url 522 | $page_path = Util::get_path_from_local_url( $this->static_page->url ); 523 | $extracted_path = Util::get_path_from_local_url( $url ); 524 | 525 | // create a path from one page to the other 526 | $path = Util::create_offline_path( $extracted_path, $page_path ); 527 | 528 | $path_info = Util::url_path_info( $url ); 529 | if ( $path_info['extension'] === '' ) { 530 | // If there's no extension, we need to add a /index.html, 531 | // and do so before any params or fragments. 532 | $clean_path = (string) Util::remove_params_and_fragment( (string) $path ); 533 | $fragment = substr( (string) $path, strlen( $clean_path ) ); 534 | 535 | $path = trailingslashit( (string) $clean_path ); 536 | $path .= 'index.html' . $fragment; 537 | } 538 | 539 | return (string) $path; 540 | } 541 | } 542 | -------------------------------------------------------------------------------- /src/Url_Fetcher.php: -------------------------------------------------------------------------------- 1 | archive_dir = Options::instance()->get_archive_dir(); 64 | } 65 | 66 | return self::$instance; 67 | } 68 | 69 | /** 70 | * Fetch the URL and return a \WP_Error if we get one, otherwise a Response class. 71 | * 72 | * @param Page $static_page URL to fetch 73 | * @return boolean Was the fetch successful? 74 | */ 75 | public function fetch( Page $static_page ) { 76 | $url = $static_page->url; 77 | 78 | $static_page->last_checked_at = (string) Util::formatted_datetime(); 79 | 80 | // Don't process URLs that don't match the URL of this WordPress installation 81 | if ( ! Util::is_local_url( $url ) ) { 82 | Util::debug_log( 'Not fetching URL because it is not a local URL' ); 83 | $static_page->http_status_code = 777; 84 | $message = 'An error occurred: Attempted to fetch a remote URL'; 85 | $static_page->set_error_message( $message ); 86 | $static_page->save(); 87 | return false; 88 | } 89 | 90 | $temp_filename = wp_tempnam(); 91 | 92 | Util::debug_log( 'Fetching URL and saving it to: ' . $temp_filename ); 93 | $response = self::remote_get( $url, $temp_filename ); 94 | 95 | $filesize = file_exists( $temp_filename ) ? filesize( $temp_filename ) : 0; 96 | Util::debug_log( 'Filesize: ' . $filesize . ' bytes' ); 97 | 98 | if ( is_wp_error( $response ) ) { 99 | Util::debug_log( 100 | 'We encountered an error when fetching: ' . $response->get_error_message() 101 | ); 102 | Util::debug_log( $response ); 103 | $static_page->http_status_code = 888; 104 | $message = sprintf( 'An error occurred: %s', $response->get_error_message() ); 105 | $static_page->set_error_message( $message ); 106 | $static_page->save(); 107 | return false; 108 | } else { 109 | $static_page->http_status_code = (int) $response['response']['code']; 110 | $static_page->content_type = $response['headers']['content-type']; 111 | $static_page->redirect_url = 112 | isset( $response['headers']['location'] ) ? $response['headers']['location'] : null; 113 | 114 | Util::debug_log( 115 | 'http_status_code: ' . $static_page->http_status_code . 116 | ' | content_type: ' . $static_page->content_type 117 | ); 118 | 119 | $relative_filename = null; 120 | if ( $static_page->http_status_code == 200 ) { 121 | // pclzip doesn't like 0 byte files (fread error), so we're 122 | // going to fix that by putting a single space into the file 123 | if ( $filesize === 0 ) { 124 | file_put_contents( $temp_filename, ' ' ); 125 | } 126 | 127 | $relative_filename = $this->create_directories_for_static_page( $static_page ); 128 | } 129 | 130 | if ( $relative_filename !== null ) { 131 | $static_page->file_path = $relative_filename; 132 | $file_path = $this->archive_dir . $relative_filename; 133 | Util::debug_log( 134 | 'Renaming temp file from ' . $temp_filename . ' to ' . $file_path 135 | ); 136 | rename( $temp_filename, $file_path ); 137 | } else { 138 | Util::debug_log( "We weren't able to establish a filename; deleting temp file" ); 139 | unlink( $temp_filename ); 140 | } 141 | 142 | $static_page->save(); 143 | 144 | return true; 145 | } 146 | } 147 | 148 | /** 149 | * Given a Static_Page, return a relative filename based on the URL 150 | * 151 | * This will also create directories as needed so that a file could be 152 | * created at the returned file path. 153 | * 154 | * @return string|null The relative file path of the file 155 | */ 156 | public function create_directories_for_static_page( Page $static_page ) { 157 | $url_parts = parse_url( $static_page->url ); 158 | 159 | if ( ! $url_parts ) { 160 | return null; 161 | } 162 | 163 | // a domain with no trailing slash has no path, so we're giving it one 164 | $path = isset( $url_parts['path'] ) ? $url_parts['path'] : '/'; 165 | 166 | $origin_path_length = strlen( (string) parse_url( Util::origin_url(), PHP_URL_PATH ) ); 167 | if ( $origin_path_length > 1 ) { // prevents removal of '/' 168 | $path = substr( $path, $origin_path_length ); 169 | } 170 | 171 | $path_info = Util::url_path_info( $path ); 172 | 173 | $relative_file_dir = $path_info['dirname']; 174 | $relative_file_dir = Util::remove_leading_directory_separator( $relative_file_dir ); 175 | 176 | // If there's no extension, we're going to create a directory with the 177 | // filename and place an index.html/xml file in there. 178 | if ( $path_info['extension'] === '' ) { 179 | if ( $path_info['filename'] !== '' ) { 180 | // the filename would be blank for the root url, in that 181 | // instance we don't want to add an extra slash 182 | $relative_file_dir .= $path_info['filename']; 183 | $relative_file_dir = Util::add_trailing_directory_separator( $relative_file_dir ); 184 | } 185 | $path_info['filename'] = 'index'; 186 | if ( $static_page->is_type( 'xml' ) ) { 187 | $path_info['extension'] = 'xml'; 188 | } else { 189 | $path_info['extension'] = 'html'; 190 | } 191 | } 192 | 193 | $create_dir = wp_mkdir_p( $this->archive_dir . $relative_file_dir ); 194 | if ( $create_dir === false ) { 195 | Util::debug_log( 196 | 'Unable to create temporary directory: ' . 197 | $this->archive_dir . $relative_file_dir 198 | ); 199 | $static_page->set_error_message( 'Unable to create temporary directory' ); 200 | } else { 201 | $relative_filename = 202 | $relative_file_dir . $path_info['filename'] . '.' . $path_info['extension']; 203 | Util::debug_log( 'New filename for static page: ' . $relative_filename ); 204 | 205 | // check that file doesn't exist OR exists but is writeable 206 | // (generally, we'd expect it to never exist) 207 | if ( ! file_exists( $relative_filename ) || is_writable( $relative_filename ) ) { 208 | return $relative_filename; 209 | } else { 210 | Util::debug_log( 'File exists and is unwriteable' ); 211 | $static_page->set_error_message( 'File exists and is unwriteable' ); 212 | } 213 | } 214 | 215 | return null; 216 | } 217 | 218 | /** 219 | * @return mixed|WP_Error 220 | */ 221 | public static function remote_get( string $url, string $filename = null ) { 222 | $basic_auth_digest = Options::instance()->get( 'http_basic_auth_digest' ); 223 | 224 | $args = [ 225 | 'timeout' => self::TIMEOUT, 226 | 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), 227 | 'redirection' => 0, // disable redirection 228 | 'blocking' => true, // do not execute code until this call is complete 229 | ]; 230 | 231 | if ( $filename ) { 232 | $args['stream'] = true; // stream body content to a file 233 | $args['filename'] = $filename; 234 | } 235 | 236 | if ( $basic_auth_digest ) { 237 | $args['headers'] = [ 'Authorization' => 'Basic ' . $basic_auth_digest ]; 238 | } 239 | 240 | $response = wp_remote_get( $url, $args ); 241 | return $response; 242 | } 243 | 244 | } 245 | -------------------------------------------------------------------------------- /src/Util.php: -------------------------------------------------------------------------------- 1 | $length + 3 ) ? 75 | ( substr( $string, 0, $length ) . $omission ) : 76 | $string; 77 | } 78 | 79 | /** 80 | * Use trailingslashit unless the string is empty 81 | */ 82 | public static function trailingslashit_unless_blank( string $string ) : string { 83 | return $string === '' ? $string : trailingslashit( $string ); 84 | } 85 | 86 | /** 87 | * Dump an object to error_log 88 | * 89 | * @param mixed $object Object to dump to the error log 90 | * @return void 91 | */ 92 | public static function error_log( $object = null ) { 93 | $contents = self::get_contents_from_object( $object ); 94 | // phpcs:disable 95 | error_log( (string) $contents ); 96 | // phpcs:enable 97 | } 98 | 99 | /** 100 | * Delete the debug log 101 | * 102 | * @return void 103 | */ 104 | public static function delete_debug_log() { 105 | $debug_file = self::get_debug_log_filename(); 106 | if ( file_exists( $debug_file ) ) { 107 | unlink( $debug_file ); 108 | } 109 | } 110 | 111 | /** 112 | * Save an object/string to the debug log 113 | * 114 | * @param mixed $object Object to save to the debug log 115 | * @return void 116 | */ 117 | public static function debug_log( $object = null ) { 118 | $options = Options::instance(); 119 | if ( $options->get( 'debugging_mode' ) !== '1' ) { 120 | return; 121 | } 122 | 123 | $debug_file = self::get_debug_log_filename(); 124 | 125 | // add timestamp and newline 126 | $message = '[' . gmdate( 'Y-m-d H:i:s' ) . '] '; 127 | 128 | $trace = debug_backtrace(); 129 | if ( isset( $trace[0]['file'] ) ) { 130 | $file = basename( $trace[0]['file'] ); 131 | if ( isset( $trace[0]['line'] ) ) { 132 | $file .= ':' . $trace[0]['line']; 133 | } 134 | $message .= '[' . $file . '] '; 135 | } 136 | 137 | $contents = self::get_contents_from_object( $object ); 138 | 139 | // get message onto a single line 140 | $contents = preg_replace( "/\r|\n/", '', (string) $contents ); 141 | 142 | $message .= $contents . "\n"; 143 | 144 | // log the message to the debug file instead of the usual error_log location 145 | // phpcs:disable 146 | error_log( $message, 3, $debug_file ); 147 | // phpcs:enable 148 | } 149 | 150 | /** 151 | * Return the filename for the debug log 152 | * 153 | * @return string Filename for the debug log 154 | */ 155 | public static function get_debug_log_filename() { 156 | $upload_path_and_url = wp_upload_dir(); 157 | $uploads_path = trailingslashit( $upload_path_and_url['basedir'] ); 158 | return $uploads_path . 'simplerstatic-debug.txt'; 159 | } 160 | 161 | /** 162 | * Get contents of an object as a string 163 | * 164 | * @param mixed $object Object to get string for 165 | * @return string|bool String containing the contents of the object 166 | */ 167 | protected static function get_contents_from_object( $object ) { 168 | if ( is_string( $object ) ) { 169 | return $object; 170 | } 171 | 172 | ob_start(); 173 | // phpcs:disable 174 | var_dump( $object ); 175 | // phpcs:enable 176 | $contents = ob_get_contents(); 177 | ob_end_clean(); 178 | 179 | return $contents; 180 | } 181 | 182 | /** 183 | * Given a URL extracted from a page, return an absolute URL 184 | * 185 | * Takes a URL (e.g. /test) extracted from a page (e.g. http://foo.com/bar/) and 186 | * returns an absolute URL (e.g. http://foo.com/bar/test). Absolute URLs are 187 | * returned as-is. Exception: links beginning with a # (hash) are left as-is. 188 | * 189 | * A null value is returned in the event that the extracted_url is blank or it's 190 | * unable to be parsed. 191 | * 192 | * @param string $extracted_url Relative or absolute URL extracted from page 193 | * @param string $page_url URL of page 194 | * @return string|null Absolute URL, or null 195 | */ 196 | public static function relative_to_absolute_url( $extracted_url, $page_url ) { 197 | 198 | $extracted_url = trim( $extracted_url ); 199 | 200 | // we can't do anything with blank urls 201 | if ( $extracted_url === '' ) { 202 | return null; 203 | } 204 | 205 | // if we get a hash, e.g. href='#section-three', just return it as-is 206 | if ( strpos( $extracted_url, '#' ) === 0 ) { 207 | return $extracted_url; 208 | } 209 | 210 | // check for a protocol-less URL 211 | // (Note: there's a bug in PHP <= 5.4.7 where parsed URLs starting with // 212 | // are treated as a path. So we're doing this check upfront.) 213 | // http://php.net/manual/en/function.parse-url.php#example-4617 214 | if ( strpos( $extracted_url, '//' ) === 0 ) { 215 | 216 | // if this is a local URL, add the protocol to the URL 217 | if ( stripos( $extracted_url, '//' . self::origin_host() ) === 0 ) { 218 | $extracted_url = self::origin_scheme() . ':' . $extracted_url; 219 | } 220 | 221 | return $extracted_url; 222 | 223 | } 224 | 225 | $parsed_extracted_url = parse_url( $extracted_url ); 226 | 227 | // parse_url can sometimes return false; bail if it does 228 | if ( $parsed_extracted_url === false ) { 229 | return null; 230 | } 231 | 232 | // if no path, check for an ending slash; if there isn't one, add one 233 | if ( ! isset( $parsed_extracted_url['path'] ) ) { 234 | $clean_url = (string) self::remove_params_and_fragment( $extracted_url ); 235 | $fragment = substr( $extracted_url, strlen( $clean_url ) ); 236 | $extracted_url = trailingslashit( $clean_url ) . $fragment; 237 | } 238 | 239 | if ( isset( $parsed_extracted_url['host'] ) ) { 240 | 241 | return $extracted_url; 242 | 243 | } elseif ( isset( $parsed_extracted_url['scheme'] ) ) { 244 | 245 | // examples of schemes without hosts: java:, data: 246 | return $extracted_url; 247 | 248 | } else { // no host on extracted page (might be relative url) 249 | $path = isset( $parsed_extracted_url['path'] ) ? 250 | $parsed_extracted_url['path'] : 251 | ''; 252 | 253 | $query = isset( $parsed_extracted_url['query'] ) ? 254 | '?' . $parsed_extracted_url['query'] : 255 | ''; 256 | $fragment = isset( $parsed_extracted_url['fragment'] ) ? 257 | '#' . $parsed_extracted_url['fragment'] : 258 | ''; 259 | 260 | // turn our relative url into an absolute url 261 | $extracted_url = \PhpUri::parse( $page_url )->join( $path . $query . $fragment ); 262 | 263 | return $extracted_url; 264 | } 265 | } 266 | 267 | /** 268 | * Recursively create a path from one page to another 269 | * 270 | * Takes a path (e.g. /blog/foobar/) extracted from a page (e.g. /blog/page/3/) 271 | * and returns a path to get to the extracted page from the current page 272 | * (e.g. ./../../foobar/index.html). Since this is for offline use, the path 273 | * return will include a /index.html if the extracted path doesn't contain 274 | * an extension. 275 | * 276 | * The function recursively calls itself, cutting off sections of the page path 277 | * until the base matches the extracted path or it runs out of parts to remove, 278 | * then it builds out the path to the extracted page. 279 | * 280 | * @param string $extracted_path Relative or absolute URL extracted from page 281 | * @param string $page_path URL of page 282 | * @param int $iterations Number of times the page path has been chopped 283 | * @return string|null Absolute URL, or null 284 | */ 285 | public static function create_offline_path( $extracted_path, $page_path, $iterations = 0 ) { 286 | // We're done if we get a match between the path of the page and the extracted URL 287 | // OR if there are no more slashes to remove 288 | if ( strpos( $page_path, '/' ) === false || strpos( $extracted_path, $page_path ) === 0 ) { 289 | $extracted_path = substr( $extracted_path, strlen( $page_path ) ); 290 | $iterations = ( $iterations == 0 ) ? 0 : $iterations - 1; 291 | $new_path = '.' . str_repeat( '/..', $iterations ) . 292 | self::add_leading_slash( $extracted_path ); 293 | return $new_path; 294 | } else { 295 | // match everything before the last slash 296 | $pattern = '/(.*)\/[^\/]*$/'; 297 | // remove the last slash and anything after it 298 | $new_page_path = preg_replace( $pattern, '$1', (string) $page_path ); 299 | return self::create_offline_path( 300 | $extracted_path, 301 | (string) $new_page_path, 302 | ++$iterations 303 | ); 304 | } 305 | } 306 | 307 | /** 308 | * Check if URL starts with same URL as WordPress installation 309 | * 310 | * Both http and https are assumed to be the same domain. 311 | * 312 | * @param string $url URL to check 313 | * @return boolean true if URL is local, false otherwise 314 | */ 315 | public static function is_local_url( $url ) { 316 | return ( 317 | stripos( (string) self::strip_protocol_from_url( $url ), self::origin_host() ) === 0 318 | ); 319 | } 320 | 321 | /** 322 | * Get the path from a local URL, removing the protocol and host 323 | * 324 | * @param string $url URL to strip protocol/host from 325 | * @return string URL sans protocol/host 326 | */ 327 | public static function get_path_from_local_url( $url ) { 328 | $url = self::strip_protocol_from_url( $url ); 329 | $url = str_replace( self::origin_host(), '', (string) $url ); 330 | return $url; 331 | } 332 | 333 | /** 334 | * Returns a URL w/o the query string or fragment (i.e. nothing after the path) 335 | * 336 | * @param string $url URL to remove query string/fragment from 337 | * @return string|null URL without query string/fragment 338 | */ 339 | public static function remove_params_and_fragment( $url ) { 340 | return preg_replace( '/(\?|#).*/', '', $url ); 341 | } 342 | 343 | /** 344 | * Converts a textarea into an array w/ each line being an entry in the array 345 | * 346 | * @param string $textarea Textarea to convert 347 | * @return mixed[] Converted array 348 | */ 349 | public static function string_to_array( $textarea ) { 350 | // using preg_split to intelligently break at newlines 351 | // see: https://stackoverflow.com/q/1483497/1668057 352 | $lines = preg_split( "/\r\n|\n|\r/", $textarea ); 353 | 354 | if ( ! is_array( $lines ) ) { 355 | return []; 356 | } 357 | 358 | array_walk( $lines, 'trim' ); 359 | $lines = array_filter( $lines ); 360 | return $lines; 361 | } 362 | 363 | /** 364 | * Remove the //, http://, https:// protocols from a URL 365 | * 366 | * @param string $url URL to remove protocol from 367 | * @return string|null URL sans http/https protocol 368 | */ 369 | public static function strip_protocol_from_url( $url ) { 370 | $pattern = '/^(https?:)?\/\//'; 371 | return preg_replace( $pattern, '', $url ); 372 | } 373 | 374 | /** 375 | * Remove index.html/index.php from a URL 376 | * 377 | * @param string $url URL to remove index file from 378 | * @return string|null URL sans index file 379 | */ 380 | public static function strip_index_filenames_from_url( $url ) { 381 | $pattern = '/index.(html?|php)$/'; 382 | return preg_replace( $pattern, '', $url ); 383 | } 384 | 385 | /** 386 | * Get the current datetime formatted as a string for entry into MySQL 387 | * 388 | * @return string|bool MySQL formatted datetime 389 | */ 390 | public static function formatted_datetime() { 391 | return gmdate( 'Y-m-d H:i:s' ); 392 | } 393 | 394 | /** 395 | * Similar to PHP's pathinfo(), but designed with URL paths in mind (instead of directories) 396 | * 397 | * Example: 398 | * $info = self::url_path_info( '/manual/en/function.pathinfo.php?test=true' ); 399 | * $info['dirname'] === '/manual/en/' 400 | * $info['basename'] === 'function.pathinfo.php' 401 | * $info['extension'] === 'php' 402 | * $info['filename'] === 'function.pathinfo' 403 | * 404 | * @param string $path The URL path 405 | * @return mixed[] Array containing info on the parts of the path 406 | */ 407 | public static function url_path_info( $path ) { 408 | $info = [ 409 | 'dirname' => '', 410 | 'basename' => '', 411 | 'filename' => '', 412 | 'extension' => '', 413 | ]; 414 | 415 | $path = self::remove_params_and_fragment( $path ); 416 | 417 | // everything after the last slash is the filename 418 | $last_slash_location = strrpos( (string) $path, '/' ); 419 | if ( $last_slash_location === false ) { 420 | $info['basename'] = $path; 421 | } else { 422 | $info['dirname'] = substr( (string) $path, 0, $last_slash_location + 1 ); 423 | $info['basename'] = substr( (string) $path, $last_slash_location + 1 ); 424 | } 425 | 426 | // finding the dot for the extension 427 | $last_dot_location = strrpos( (string) $info['basename'], '.' ); 428 | if ( $last_dot_location === false ) { 429 | $info['filename'] = $info['basename']; 430 | } else { 431 | $info['filename'] = substr( (string) $info['basename'], 0, $last_dot_location ); 432 | $info['extension'] = substr( (string) $info['basename'], $last_dot_location + 1 ); 433 | } 434 | 435 | // substr sets false if it fails, we're going to reset those values to '' 436 | foreach ( $info as $name => $value ) { 437 | if ( ! $value ) { 438 | $info[ $name ] = ''; 439 | } 440 | } 441 | 442 | return $info; 443 | } 444 | 445 | /** 446 | * Ensure there is a single trailing directory separator on the path 447 | * 448 | * @param string $path File path to add trailing directory separator to 449 | */ 450 | public static function add_trailing_directory_separator( $path ) : string { 451 | return self::remove_trailing_directory_separator( $path ) . DIRECTORY_SEPARATOR; 452 | } 453 | 454 | /** 455 | * Remove all trailing directory separators 456 | * 457 | * @param string $path File path to remove trailing directory separators from 458 | */ 459 | public static function remove_trailing_directory_separator( $path ) : string { 460 | return rtrim( $path, DIRECTORY_SEPARATOR ); 461 | } 462 | 463 | /** 464 | * Ensure there is a single leading directory separator on the path 465 | * 466 | * @param string $path File path to add leading directory separator to 467 | */ 468 | public static function add_leading_directory_separator( $path ) : string { 469 | return DIRECTORY_SEPARATOR . self::remove_leading_directory_separator( $path ); 470 | } 471 | 472 | /** 473 | * Remove all leading directory separators 474 | * 475 | * @param string $path File path to remove leading directory separators from 476 | */ 477 | public static function remove_leading_directory_separator( $path ) : string { 478 | return ltrim( $path, DIRECTORY_SEPARATOR ); 479 | } 480 | 481 | /** 482 | * Add a slash to the beginning of a path 483 | * 484 | * @param string $path URL path to add leading slash to 485 | */ 486 | public static function add_leading_slash( $path ) : string { 487 | return '/' . self::remove_leading_slash( $path ); 488 | } 489 | 490 | /** 491 | * Remove a slash from the beginning of a path 492 | * 493 | * @param string $path URL path to remove leading slash from 494 | */ 495 | public static function remove_leading_slash( $path ) : string { 496 | return ltrim( $path, '/' ); 497 | } 498 | 499 | /** 500 | * Add a message to the array of status messages for the job 501 | * 502 | * @param mixed[] $messages Array of messages to add the message to 503 | * @param string $task_name Name of the task 504 | * @param string $message Message to display about the status of the job 505 | * @return mixed[] messages 506 | */ 507 | public static function add_archive_status_message( $messages, $task_name, $message ) { 508 | // if the state exists, set the datetime and message 509 | if ( ! array_key_exists( $task_name, $messages ) ) { 510 | $messages[ $task_name ] = [ 511 | 'message' => $message, 512 | 'datetime' => self::formatted_datetime(), 513 | ]; 514 | } else { // otherwise just update the message 515 | $messages[ $task_name ]['message'] = $message; 516 | } 517 | 518 | return $messages; 519 | } 520 | 521 | } 522 | -------------------------------------------------------------------------------- /src/View.php: -------------------------------------------------------------------------------- 1 | path = implode( '/', $path_array ); 72 | } 73 | 74 | /** 75 | * Sets a layout that will be used later in render() method 76 | */ 77 | public function set_layout( string $layout ) : View { 78 | $this->layout = trailingslashit( $this->path ) . 'layouts/' . $layout . self::EXTENSION; 79 | 80 | return $this; 81 | } 82 | 83 | /** 84 | * Sets a template that will be used later in render() method 85 | * 86 | * @param string $template The template filename, without extension 87 | * @return View 88 | */ 89 | public function set_template( $template ) { 90 | $this->template = trailingslashit( $this->path ) . $template . self::EXTENSION; 91 | 92 | return $this; 93 | } 94 | 95 | /** 96 | * Returns a value of the option identified by $name 97 | * 98 | * @param string $name The option name 99 | * @return mixed|null 100 | */ 101 | public function __get( $name ) { 102 | $value = array_key_exists( $name, $this->variables ) ? $this->variables[ $name ] : null; 103 | return $value; 104 | } 105 | 106 | /** 107 | * Updates the view variable identified by $name with the value provided in $value 108 | * 109 | * @param string $name The variable name 110 | * @param mixed $value The variable value 111 | * @return View 112 | */ 113 | public function __set( $name, $value ) { 114 | $this->variables[ $name ] = $value; 115 | return $this; 116 | } 117 | 118 | /** 119 | * Updates the view variable identified by $name with the value provided in $value 120 | * 121 | * @param string $name The variable name 122 | * @param mixed $value The variable value 123 | * @return View 124 | */ 125 | public function assign( $name, $value ) { 126 | return $this->__set( $name, $value ); 127 | } 128 | 129 | /** 130 | * Add a flash message to be displayed at the top of the page 131 | * 132 | * Available types: 'updated' (green), 'error' (red), 'notice' (no color) 133 | * 134 | * @param string $type The type of message to be displayed 135 | * @param string $message The message to be displayed 136 | * @return void 137 | */ 138 | public function add_flash( $type, $message ) { 139 | array_push( 140 | $this->flashes, 141 | [ 142 | 'type' => $type, 143 | 'message' => $message, 144 | ] 145 | ); 146 | } 147 | 148 | /** 149 | * Returns the layout (if available) or template 150 | * 151 | * Checks to make sure that the file exists and is readable. 152 | * 153 | * @return string|\WP_Error 154 | */ 155 | private function get_renderable_file() { 156 | 157 | // must include a template 158 | if ( ! is_readable( $this->template ) ) { 159 | return new \WP_Error( 160 | 'invalid_template', 161 | sprintf( "Can't find view template: %s", $this->template ) 162 | ); 163 | } 164 | 165 | // layouts are optional. if no layout provided, use the template by itself. 166 | if ( $this->layout ) { 167 | if ( ! is_readable( $this->layout ) ) { 168 | return new \WP_Error( 169 | 'invalid_layout', 170 | sprintf( "Can't find view layout: %s", $this->layout ) 171 | ); 172 | } else { 173 | // the layout should include the template 174 | return $this->layout; 175 | } 176 | } else { 177 | return $this->template; 178 | } 179 | 180 | } 181 | 182 | /** 183 | * Renders the view script. 184 | * 185 | * @return View|\WP_Error 186 | */ 187 | public function render() { 188 | 189 | $file = $this->get_renderable_file(); 190 | 191 | if ( is_wp_error( $file ) ) { 192 | return $file; 193 | } else { 194 | include $file; 195 | return $this; 196 | } 197 | 198 | } 199 | 200 | /** 201 | * Returns the view as a string. 202 | * 203 | * @return string|\WP_Error|bool 204 | */ 205 | public function render_to_string() { 206 | 207 | $file = $this->get_renderable_file(); 208 | 209 | if ( is_wp_error( $file ) ) { 210 | return $file; 211 | } else { 212 | ob_start(); 213 | include $file; 214 | return ob_get_clean(); 215 | } 216 | 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/Wrapup_Task.php: -------------------------------------------------------------------------------- 1 | options->get( 'delete_temp_files' ) === '1' ) { 17 | Util::debug_log( 'Deleting temporary files' ); 18 | $this->save_status_message( __( 'Wrapping up', 'simplerstatic' ) ); 19 | $deleted_successfully = $this->delete_temp_static_files(); 20 | } else { 21 | Util::debug_log( 'Keeping temporary files' ); 22 | } 23 | 24 | return true; 25 | } 26 | 27 | /** 28 | * Delete temporary, generated static files 29 | * 30 | * @return true|\WP_Error True on success, WP_Error otherwise 31 | */ 32 | public function delete_temp_static_files() { 33 | $archive_dir = $this->options->get_archive_dir(); 34 | 35 | if ( file_exists( $archive_dir ) ) { 36 | $directory_iterator = new RecursiveDirectoryIterator( 37 | $archive_dir, 38 | FilesystemIterator::SKIP_DOTS 39 | ); 40 | $recursive_iterator = new RecursiveIteratorIterator( 41 | $directory_iterator, 42 | RecursiveIteratorIterator::CHILD_FIRST 43 | ); 44 | 45 | // recurse through the entire directory and delete all files / subdirectories 46 | foreach ( $recursive_iterator as $item ) { 47 | $success = $item->isDir() ? rmdir( $item ) : unlink( $item ); 48 | if ( ! $success ) { 49 | $message = 50 | sprintf( 'Could not delete temporary file or directory: %s', $item ); 51 | $this->save_status_message( $message ); 52 | return true; 53 | } 54 | } 55 | 56 | // must make sure to delete the original directory at the end 57 | $success = rmdir( $archive_dir ); 58 | if ( ! $success ) { 59 | $message = 60 | sprintf( 'Could not delete temporary file or directory: %s', $archive_dir ); 61 | $this->save_status_message( $message ); 62 | return true; 63 | } 64 | } 65 | 66 | return true; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/phpstan/bootstrap.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | WP2Static Project Coding Standard 4 | 5 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /uninstall.php: -------------------------------------------------------------------------------- 1 | status_messages as $state_name => $status ) : ?> 5 |
'>[]
6 | 7 | -------------------------------------------------------------------------------- /views/_export_log.php: -------------------------------------------------------------------------------- 1 | static_pages ) && count( $this->static_pages ) ) : ?> 5 | 6 | static_pages, function($p) { return $p->error_message != false; } ) ); ?> 7 | 8 |
9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 0 ) : ?> 19 | 20 | 21 | 22 | 23 | 24 | 25 | static_pages as $static_page ) : ?> 26 | 27 | status_message !== 'Additional Dir; Do not save or follow' ) : ?> 28 | 29 | 30 | http_status_code, Page::$processable_status_codes ); ?> 31 | 34 | 35 | 50 | 0 ) : ?> 51 | 54 | 55 | 56 | 57 | 58 | 59 |
CodeURLNotes
'> 32 | http_status_code === 666 ? 'skip' : $static_page->http_status_code; ?> 33 | url; ?> 36 | parent_static_page(); 39 | if ( $parent_static_page ) { 40 | $display_url = Util::get_path_from_local_url( $parent_static_page->url ); 41 | $msg .= "" .sprintf( 'Found on %s', $display_url ). ""; 42 | } 43 | if ( $msg !== '' && $static_page->status_message ) { 44 | $msg .= '; '; 45 | } 46 | $msg .= $static_page->status_message; 47 | echo $msg; 48 | ?> 49 | 52 | error_message; ?> 53 |
60 | 61 |
62 | 63 |
64 | 65 | 66 | -------------------------------------------------------------------------------- /views/_pagination.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | HTTP status codes: 4 | 1xx Informational: http_status_codes['1']; ?> | 5 | 2xx Success: http_status_codes['2']; ?> | 6 | 3xx Redirection: http_status_codes['3']; ?> | 7 | 4xx Client Error: http_status_codes['4']; ?> | 8 | 5xx Server Error: http_status_codes['5']; ?> | 9 | Skipped: http_status_codes['6']; ?> | 10 | External: http_status_codes['7']; ?> | 11 | Other errors: http_status_codes['8']; ?> 12 |
13 |
14 | 15 |
16 | total_static_pages );?> 17 | '?page=%#%', 20 | 'total' => $this->total_pages, 21 | 'current' => $this->current_page, 22 | 'prev_text' => '‹', 23 | 'next_text' => '›' 24 | ); 25 | echo paginate_links( $args ); 26 | ?> 27 |
28 | -------------------------------------------------------------------------------- /views/diagnostics.php: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Simpler Static › Diagnostics

6 | 7 |
8 | 9 | results as $title => $tests ) : ?> 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | themes as $theme ) : ?> 42 | 43 | 44 | 45 | 46 | get( 'Name') === $this->current_theme_name ) : ?> 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
Theme NameTheme URLVersionEnabled
get( 'Name'); ?>'>get( 'ThemeURI'); ?>get( 'Version'); ?>YesNo
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | plugins as $plugin_path => $plugin_data ) : ?> 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 |
Plugin NamePlugin URLVersionEnabled
'>YesNo
80 | 81 |

Debugging Options

82 | 83 |
84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 101 | 102 | 103 | 104 | 109 | 110 | 111 |
Debugging Mode 93 | 97 |

98 | When enabled, a debug log will be created when generating static files. 99 |

100 |
105 |

106 | 107 |

108 |
112 | 113 |
114 | 115 |
116 | 117 | -------------------------------------------------------------------------------- /views/generate.php: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Simpler Static › Generate

6 | 7 |
8 | 9 | 10 | 11 |
12 | ' type='submit' name='generate' value='Generate Static Files' /> 13 | 14 | ' type='submit' name='cancel' value='Cancel' /> 15 | 16 | '> 17 |
18 | 19 |

Activity Log

20 |
21 | activity_log; ?> 22 |
23 | 24 |

Export Log

25 |
26 | export_log; ?> 27 |
28 | 29 |
30 | -------------------------------------------------------------------------------- /views/layouts/admin.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | flashes as $flash ) : ?> 6 |
7 |

8 | 9 |

10 |
11 | 12 | 13 |
14 |
15 | 16 |
17 | template; ?> 18 |
19 | 20 |
21 | 22 |
23 | 24 | -------------------------------------------------------------------------------- /views/redirect.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redirecting... 5 | 6 | 7 | 8 | 11 | 12 |

redirect_url . '">' . $this->redirect_url . '' ); ?>

13 | 14 | 15 | -------------------------------------------------------------------------------- /views/settings.php: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Simpler Static › Settings

6 | 7 |
8 | 9 | 15 | 16 |
17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 28 | 31 | 32 | 33 | 34 | 49 | 50 | 51 | 52 | 65 | 66 | 67 | 68 | 79 | 80 | 81 | 83 | 89 | 90 | 91 | 92 | 95 | 96 | 97 | 98 | 101 | 102 | 103 | 106 | 114 | 115 | 116 | 117 | 122 | 123 | 124 |
26 | 27 | 29 |

When exporting your static site, any links to your WordPress site will be replaced by one of the following: absolute URLs, relative URLs, or URLs contructed for offline use.

30 |
35 | 36 | destination_url_type === 'absolute' ); ?>> 37 | 38 | 39 |

40 | 46 |

Convert all URLs for your WordPress site to absolute URLs at the domain specified above.

47 |
48 |
53 | 54 | destination_url_type === 'relative' ); ?>> 55 | 56 | 57 |

58 | 59 |
60 |

Convert all URLs for your WordPress site to relative URLs that will work at any domain. Optionally specify a path above if you intend to place the files in a subdirectory.

61 |

Example: enter /path/ above if you wanted to serve your files at www.example.com/path/

62 |
63 |
64 |
69 | 70 | destination_url_type === 'offline' ); ?>> 71 | 72 | 73 |

74 |

75 | Convert all URLs for your WordPress site so that you can browse the site locally on your own computer without hosting it on a web server. 76 |

77 |
78 |
82 | 84 | 88 |
93 |

Saving your static files to a ZIP archive is Simpler Static's default delivery method. After generating your static files you will be provided with a link to download the ZIP archive.

94 |
99 |

Saving your static files to a local directory is useful if you want to serve your static files from the same server as your WordPress installation. WordPress can live on a subdomain (e.g. wordpress.example.com) while your static files are served from your primary domain (e.g. www.example.com).

100 |
104 | 105 | 107 | 108 | 109 |
110 |

This is the directory where your static files will be saved. The directory must exist and be writeable by the webserver.

111 |

%s", $example_local_dir ); ?>

112 |
113 |
118 |

119 | 120 |

121 |
125 |
126 | 127 |
128 | 129 | 130 | 131 | 134 | 143 | 144 | 145 | 148 | 157 | 158 | 159 | 162 | 206 | 207 | 208 | 209 | 214 | 215 | 216 |
132 | 133 | 135 | 136 |
137 |

aren't linked to, add the URLs here (one per line).", trailingslashit( Util::origin_url() ) ); ?>

138 |

%s or %s", 139 | Util::origin_url() . "/hidden-page/", 140 | Util::origin_url() . "/images/secret.jpg" ); ?>

141 |
142 |
146 | 147 | 149 | 150 |
151 |

Sometimes you may want to include additional files (such as files referenced via AJAX) or directories. Add the paths to those files or directories here (one per line).

152 |

%s or %s", 153 | get_home_path() . "additional-directory/", 154 | trailingslashit( WP_CONTENT_DIR ) . "fancy.pdf" ); ?>

155 |
156 |
160 | 161 | 163 | urls_to_exclude; 165 | array_unshift( $urls_to_exclude, array( 166 | 'url' => '', 167 | 'do_not_save' => '1', 168 | 'do_not_follow' => '1' 169 | ) ); 170 | ?> 171 |
172 | $url_to_exclude ) : ?> 173 |
> 174 | ' size='40' /> 175 | 176 | 181 | 182 | 187 | 188 | 189 |
190 | 191 |
192 | 193 |
194 | 195 |
196 | 197 |
198 |

In this section you can specify URLs, or parts of a URL, to exclude from Simpler Static's processing. You may also use regex to specify a pattern to match.

199 |

Do not save: do not save a static copy of the page/file — Do not follow: do not use this page to find additional URLs for processing

200 |

%s would exclude %s and other files containing %s from processing", 201 | ".jpg", 202 | Util::origin_url() . "/wp-content/uploads/image.jpg", 203 | ".jpg" ); ?>

204 |
205 |
210 |

211 | 212 |

213 |
217 |
218 | 219 |
220 |

Temporary Files

221 |

Your static files are temporarily saved to a directory before being copied to their destination or creating a ZIP.

222 | 223 | 224 | 225 | 228 | 236 | 237 | 238 | 241 | 248 | 249 | 250 |
226 | 227 | 229 | 230 | 231 |
232 |

Specify the directory to save your temporary files. This directory must exist and be writeable.

233 |

%s", $example_temp_files_dir ); ?>

234 |
235 |
239 | 240 | 242 | 247 |
251 | 252 |

HTTP Basic Authentication

253 |

If you've secured WordPress with HTTP Basic Auth you can specify the username and password to use below.

254 | http_basic_auth_digest != null ) : ?> 255 | 256 | 257 | 258 | 261 | 264 | 265 | 266 |
259 | 260 | 262 |

Your basic auth credentials have been saved. To disable basic auth or set a new username/password, click here.

263 |
267 | 268 | ' id='basicAuthUserPass'> 269 | 270 | 271 | 274 | 277 | 278 | 279 | 282 | 285 | 286 | 287 |
272 | 273 | 275 | http_basic_auth_digest != null ) echo 'disabled' ?> /> 276 |
280 | 281 | 283 | http_basic_auth_digest != null ) echo 'disabled' ?> /> 284 |
288 | 289 | 290 | 291 | 292 | 293 | 298 | 299 | 300 |
294 |

295 | 296 |

297 |
301 |
302 |
303 | 304 |
305 | 306 | 307 | 308 | 309 |
310 | 311 | 312 | 313 | 316 | 322 | 323 | 324 |
314 | 315 | 317 | 318 |

319 | This will reset Simpler Static's settings back to their defaults. 320 |

321 |
325 |
326 | 327 |
328 | 329 |
330 | --------------------------------------------------------------------------------