├── .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 | [](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 | Code |
16 | URL |
17 | Notes |
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 | '>
32 | http_status_code === 666 ? 'skip' : $static_page->http_status_code; ?>
33 | |
34 | url; ?> |
35 |
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 | |
50 | 0 ) : ?>
51 |
52 | error_message; ?>
53 | |
54 |
55 |
56 |
57 |
58 |
59 |
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 | Theme Name |
35 | Theme URL |
36 | Version |
37 | Enabled |
38 |
39 |
40 |
41 | themes as $theme ) : ?>
42 |
43 | get( 'Name'); ?> |
44 | '>get( 'ThemeURI'); ?> |
45 | get( 'Version'); ?> |
46 | get( 'Name') === $this->current_theme_name ) : ?>
47 | Yes |
48 |
49 | No |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | Plugin Name |
60 | Plugin URL |
61 | Version |
62 | Enabled |
63 |
64 |
65 |
66 | plugins as $plugin_path => $plugin_data ) : ?>
67 |
68 | |
69 | '> |
70 | |
71 |
72 | Yes |
73 |
74 | No |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
Debugging Options
82 |
83 |
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 |
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 |
303 |
304 |
328 |
329 |
330 |
--------------------------------------------------------------------------------