Completed
Push — master ( d6dd65...5463c4 )
by David
02:47
created

WP_Async_Task::launch()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 21
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 13
nc 3
nop 0
dl 0
loc 21
rs 9.3142
c 0
b 0
f 0
1
<?php
2
/**
3
 * Plugin Name: WP Asynchronous Tasks
4
 * Version: 1.0
5
 * Description: Creates an abstract class to execute asynchronous tasks
6
 * Author: 10up, Eric Mann, Luke Gedeon, John P. Bloch
7
 * License: MIT
8
 */
9
10
if ( ! class_exists( 'WP_Async_Task' ) ) {
11
	abstract class WP_Async_Task {
12
13
		/**
14
		 * Constant identifier for a task that should be available to logged-in users
15
		 *
16
		 * See constructor documentation for more details.
17
		 */
18
		const LOGGED_IN = 1;
19
20
		/**
21
		 * Constant identifier for a task that should be available to logged-out users
22
		 *
23
		 * See constructor documentation for more details.
24
		 */
25
		const LOGGED_OUT = 2;
26
27
		/**
28
		 * Constant identifier for a task that should be available to all users regardless of auth status
29
		 *
30
		 * See constructor documentation for more details.
31
		 */
32
		const BOTH = 3;
33
34
		/**
35
		 * This is the argument count for the main action set in the constructor. It
36
		 * is set to an arbitrarily high value of twenty, but can be overridden if
37
		 * necessary
38
		 *
39
		 * @var int
40
		 */
41
		protected $argument_count = 20;
42
43
		/**
44
		 * Priority to fire intermediate action.
45
		 *
46
		 * @var int
47
		 */
48
		protected $priority = 10;
49
50
		/**
51
		 * @var string
52
		 */
53
		protected $action;
54
55
		/**
56
		 * @var array
57
		 */
58
		protected $_body_data;
59
60
		/**
61
		 * Constructor to wire up the necessary actions
62
		 *
63
		 * Which hooks the asynchronous postback happens on can be set by the
64
		 * $auth_level parameter. There are essentially three options: logged in users
65
		 * only, logged out users only, or both. Set this when you instantiate an
66
		 * object by using one of the three class constants to do so:
67
		 *  - LOGGED_IN
68
		 *  - LOGGED_OUT
69
		 *  - BOTH
70
		 * $auth_level defaults to BOTH
71
		 *
72
		 * @throws Exception If the class' $action value hasn't been set
73
		 *
74
		 * @param int $auth_level The authentication level to use (see above)
75
		 */
76
		public function __construct( $auth_level = self::BOTH ) {
77
			if ( empty( $this->action ) ) {
78
				throw new Exception( 'Action not defined for class ' . __CLASS__ );
79
			}
80
			add_action( $this->action, array(
81
				$this,
82
				'launch',
83
			), (int) $this->priority, (int) $this->argument_count );
84
			if ( $auth_level & self::LOGGED_IN ) {
85
				add_action( "admin_post_wp_async_$this->action", array(
86
					$this,
87
					'handle_postback',
88
				) );
89
			}
90
			if ( $auth_level & self::LOGGED_OUT ) {
91
				add_action( "admin_post_nopriv_wp_async_$this->action", array(
92
					$this,
93
					'handle_postback',
94
				) );
95
			}
96
		}
97
98
		/**
99
		 * Add the shutdown action for launching the real postback if we don't
100
		 * get an exception thrown by prepare_data().
101
		 *
102
		 * @uses func_get_args() To grab any arguments passed by the action
103
		 */
104
		public function launch() {
105
			$data = func_get_args();
106
			try {
107
				$data = $this->prepare_data( $data );
108
			} catch ( Exception $e ) {
109
				return;
110
			}
111
112
			$data['action'] = "wp_async_$this->action";
113
			$data['_nonce'] = $this->create_async_nonce();
114
115
			$this->_body_data = $data;
116
117
			if ( ! has_action( 'shutdown', array(
118
				$this,
119
				'launch_on_shutdown',
120
			) )
121
			) {
122
				add_action( 'shutdown', array( $this, 'launch_on_shutdown' ) );
123
			}
124
		}
125
126
		/**
127
		 * Launch the request on the WordPress shutdown hook
128
		 *
129
		 * On VIP we got into data races due to the postback sometimes completing
130
		 * faster than the data could propogate to the database server cluster.
131
		 * This made WordPress get empty data sets from the database without
132
		 * failing. On their advice, we're moving the actual firing of the async
133
		 * postback to the shutdown hook. Supposedly that will ensure that the
134
		 * data at least has time to get into the object cache.
135
		 *
136
		 * @uses $_COOKIE        To send a cookie header for async postback
137
		 * @uses apply_filters()
138
		 * @uses admin_url()
139
		 * @uses wp_remote_post()
140
		 */
141
		public function launch_on_shutdown() {
0 ignored issues
show
Coding Style introduced by
launch_on_shutdown uses the super-global variable $_COOKIE which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
142
			if ( ! empty( $this->_body_data ) ) {
143
				$cookies = array();
144
				foreach ( $_COOKIE as $name => $value ) {
145
					$cookies[] = "$name=" . urlencode( is_array( $value ) ? serialize( $value ) : $value );
146
				}
147
148
				$request_args = array(
149
					'timeout'   => 0.01,
150
					'blocking'  => false,
151
					'sslverify' => apply_filters( 'https_local_ssl_verify', true ),
152
					'body'      => $this->_body_data,
153
					'headers'   => array(
154
						'cookie' => implode( '; ', $cookies ),
155
					),
156
				);
157
158
				$url = admin_url( 'admin-post.php' );
159
160
				error_log( "Launching [ url :: $url ][ " . var_export( $request_args, true ) . " ]..." );
161
				error_log( var_export( wp_remote_post( $url, $request_args ), true ) );
162
			}
163
		}
164
165
		/**
166
		 * Verify the postback is valid, then fire any scheduled events.
167
		 *
168
		 * @uses $_POST['_nonce']
169
		 * @uses is_user_logged_in()
170
		 * @uses add_filter()
171
		 * @uses wp_die()
172
		 */
173
		public function handle_postback() {
0 ignored issues
show
Coding Style introduced by
handle_postback uses the super-global variable $_POST which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
174
			if ( isset( $_POST['_nonce'] ) && $this->verify_async_nonce( $_POST['_nonce'] ) ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->verify_async_nonce($_POST['_nonce']) of type integer|false is loosely compared to true; this is ambiguous if the integer can be zero. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
175
				if ( ! is_user_logged_in() ) {
176
					$this->action = "nopriv_$this->action";
177
				}
178
				$this->run_action();
179
			}
180
181
			add_filter( 'wp_die_handler', function () {
182
				die();
0 ignored issues
show
Coding Style Compatibility introduced by
The method handle_postback() contains an exit expression.

An exit expression should only be used in rare cases. For example, if you write a short command line script.

In most cases however, using an exit expression makes the code untestable and often causes incompatibilities with other libraries. Thus, unless you are absolutely sure it is required here, we recommend to refactor your code to avoid its usage.

Loading history...
183
			} );
184
			wp_die();
185
		}
186
187
		/**
188
		 * Create a random, one time use token.
189
		 *
190
		 * Based entirely on wp_create_nonce() but does not tie the nonce to the
191
		 * current logged-in user.
192
		 *
193
		 * @uses wp_nonce_tick()
194
		 * @uses wp_hash()
195
		 *
196
		 * @return string The one-time use token
197
		 */
198
		protected function create_async_nonce() {
199
			$action = $this->get_nonce_action();
200
			$i      = wp_nonce_tick();
201
202
			return substr( wp_hash( $i . $action . get_class( $this ), 'nonce' ), - 12, 10 );
203
		}
204
205
		/**
206
		 * Verify that the correct nonce was used within the time limit.
207
		 *
208
		 * @uses wp_nonce_tick()
209
		 * @uses wp_hash()
210
		 *
211
		 * @param string $nonce Nonce to be verified
212
		 *
213
		 * @return bool Whether the nonce check passed or failed
214
		 */
215
		protected function verify_async_nonce( $nonce ) {
216
			$action = $this->get_nonce_action();
217
			$i      = wp_nonce_tick();
218
219
			// Nonce generated 0-12 hours ago
220 View Code Duplication
			if ( substr( wp_hash( $i . $action . get_class( $this ), 'nonce' ), - 12, 10 ) == $nonce ) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
221
				return 1;
222
			}
223
224
			// Nonce generated 12-24 hours ago
225 View Code Duplication
			if ( substr( wp_hash( ( $i - 1 ) . $action . get_class( $this ), 'nonce' ), - 12, 10 ) == $nonce ) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
226
				return 2;
227
			}
228
229
			// Invalid nonce
230
			return false;
231
		}
232
233
		/**
234
		 * Get a nonce action based on the $action property of the class
235
		 *
236
		 * @return string The nonce action for the current instance
237
		 */
238
		protected function get_nonce_action() {
239
			$action = $this->action;
240
			if ( substr( $action, 0, 7 ) === 'nopriv_' ) {
241
				$action = substr( $action, 7 );
242
			}
243
			$action = "wp_async_$action";
244
245
			return $action;
246
		}
247
248
		/**
249
		 * Prepare any data to be passed to the asynchronous postback
250
		 *
251
		 * The array this function receives will be a numerically keyed array from
252
		 * func_get_args(). It is expected that you will return an associative array
253
		 * so that the $_POST values used in the asynchronous call will make sense.
254
		 *
255
		 * The array you send back may or may not have anything to do with the data
256
		 * passed into this method. It all depends on the implementation details and
257
		 * what data is needed in the asynchronous postback.
258
		 *
259
		 * Do not set values for 'action' or '_nonce', as those will get overwritten
260
		 * later in launch().
261
		 *
262
		 * @throws Exception If the postback should not occur for any reason
263
		 *
264
		 * @param array $data The raw data received by the launch method
265
		 *
266
		 * @return array The prepared data
267
		 */
268
		abstract protected function prepare_data( $data );
269
270
		/**
271
		 * Run the do_action function for the asynchronous postback.
272
		 *
273
		 * This method needs to fetch and sanitize any and all data from the $_POST
274
		 * superglobal and provide them to the do_action call.
275
		 *
276
		 * The action should be constructed as "wp_async_task_$this->action"
277
		 */
278
		abstract protected function run_action();
279
280
	}
281
282
}
283
284