PluginProbe
Site Reviews / trunk
Site Reviews vtrunk
8.3.1 8.3.0 8.2.2 8.2.1 8.2.0 8.1.0 8.0.13 8.0.12 8.0.11 trunk 1.2.2 2.17.1 3.5.4 4.7.0 5.25.1 6.11.8 7.0.10 7.0.11 7.0.12 7.0.13 7.0.14 7.0.15 7.0.16 7.0.17 7.0.18 All 54 releases
site-reviews / plugin / Router.php

Router.php in Site Reviews trunk, at plugin/Router.php

262 lines 8.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace GeminiLabs\SiteReviews;
4
5 use GeminiLabs\SiteReviews\Contracts\ControllerContract;
6 use GeminiLabs\SiteReviews\Modules\Mutex;
7 use GeminiLabs\SiteReviews\Modules\Notice;
8
9 class Router implements ControllerContract
10 {
11 use HookProxy;
12
13 /**
14 * @action wp_ajax_glsr_admin_action
15 */
16 public function routeAdminAjaxRequest(): void
17 {
18 $request = Request::inputPost();
19 $this->checkAjaxRequest($request);
20 $this->checkAjaxNonce($request, 'admin');
21 $this->checkMutexRequest($request);
22 $this->post('ajax', $request);
23 wp_die();
24 }
25
26 /**
27 * A routed admin GET request will look like this: /wp-admin/?glsr_=.
28 *
29 * @action admin_init
30 */
31 public function routeAdminGetRequest(): void
32 {
33 $request = Request::inputGet();
34 if (!empty($request->action)) {
35 $this->get('admin', $request);
36 }
37 }
38
39 /**
40 * @action admin_init
41 */
42 public function routeAdminPostRequest(): void
43 {
44 $request = Request::inputPost();
45 if (!$this->isValidRequest($request)) {
46 return;
47 }
48 check_admin_referer($request->_action); // die() called if nonce is invalid, assumes _wpnonce
49 if (!$this->isValidMutexRequest($request)) {
50 return;
51 }
52 $this->post('admin', $request);
53 }
54
55 /**
56 * @action wp_ajax_glsr_public_action
57 * @action wp_ajax_nopriv_glsr_public_action
58 */
59 public function routePublicAjaxRequest(): void
60 {
61 $request = Request::inputPost();
62 $this->checkAjaxRequest($request);
63 $this->checkAjaxNonce($request, 'public');
64 $this->checkMutexRequest($request);
65 $this->post('ajax', $request);
66 wp_die();
67 }
68
69 /**
70 * A routed public GET request will look like this: ?glsr_=.
71 *
72 * @action parse_request
73 */
74 public function routePublicGetRequest(): void
75 {
76 $request = Request::inputGet();
77 if (!empty($request->action)) {
78 $this->get('public', $request);
79 }
80 }
81
82 /**
83 * @action init
84 */
85 public function routePublicPostRequest(): void
86 {
87 if (glsr()->isAdmin()) {
88 return;
89 }
90 if ($this->isRestRequest()) {
91 return; // Api\Version1 owns REST submissions; see isRestRequest()
92 }
93 $request = Request::inputPost();
94 if (!$this->isValidRequest($request)) {
95 return;
96 }
97 if (!$this->isValidPublicNonce($request)) {
98 return;
99 }
100 if (!$this->isValidMutexRequest($request)) {
101 return;
102 }
103 $this->post('public', $request);
104 }
105
106 protected function checkAjaxNonce(Request $request, string $type): void
107 {
108 $unguardedActions = 'admin' === $type
109 ? $this->unguardedAdminActions()
110 : $this->unguardedPublicActions();
111 if (in_array($request->_action, $unguardedActions)) {
112 return;
113 }
114 if (empty($request->_nonce)) {
115 $this->sendAjaxError('AJAX request is missing a nonce', $request, 400, 'Unauthorized request');
116 }
117 if (!wp_verify_nonce($request->_nonce, $request->_action)) {
118 $this->sendAjaxError('AJAX request failed the nonce check', $request, 403, 'Unauthorized request');
119 }
120 }
121
122 protected function checkAjaxRequest(Request $request): void
123 {
124 if (empty($request->_action)) {
125 $this->sendAjaxError('AJAX request must include an action', $request, 400, 'Bad Request');
126 }
127 if (empty($request->_ajax_request)) {
128 $this->sendAjaxError('AJAX request is invalid', $request, 400, 'Bad Request');
129 }
130 }
131
132 protected function checkMutexRequest(Request $request): void
133 {
134 if (!$this->isValidMutexRequest($request)) {
135 $this->sendAjaxError('Parallel AJAX request (possible single-packet attack)', $request, 429, 'Too Many Requests');
136 }
137 }
138
139 protected function get(string $type, Request $request): void
140 {
141 $hook = "route/get/{$type}/{$request->action}";
142 glsr()->action('route/request', $request, $hook);
143 if (!$this->isRouted($hook)) {
144 glsr_log()->warning("Unknown {$type} router GET request: {$request->action}");
145 return;
146 }
147 glsr()->action($hook, $request);
148 }
149
150 /**
151 * Whether anything is listening on a route.
152 *
153 * It is asked AFTER the route/request action has fired, so that an addon which
154 * registers its own route from there still counts as a listener.
155 */
156 protected function isRouted(string $hook): bool
157 {
158 return false !== has_action(glsr()->id."/{$hook}");
159 }
160
161 /**
162 * Whether the request targets the REST API. This route runs on `init`, which fires
163 * before `parse_request` defines REST_REQUEST, so wp_is_serving_rest_request() cannot
164 * answer yet. A REST review submission carries the form's own _action field; without
165 * this check the no-JS fallback route would consume it before the REST server dispatches.
166 */
167 protected function isRestRequest(): bool
168 {
169 if (!empty(filter_input(\INPUT_GET, 'rest_route'))) {
170 return true; // plain permalinks
171 }
172 // Plain permalinks: the REST API is only addressable with ?rest_route=,
173 // and rest_url() is home_url('index.php?rest_route=/') — its path would
174 // classify every /index.php request as REST.
175 if (!get_option('permalink_structure')) {
176 return false;
177 }
178 $restPath = (string) parse_url(rest_url(), \PHP_URL_PATH);
179 if (in_array($restPath, ['', '/'])) {
180 return false; // a filtered rest_url with no path: only ?rest_route= reaches the REST API
181 }
182 $requestPath = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), \PHP_URL_PATH);
183 return str_starts_with($requestPath, $restPath);
184 }
185
186 protected function isValidMutexRequest(Request $request): bool
187 {
188 return glsr(Mutex::class)->isValid((string) $request->_action);
189 }
190
191 protected function isValidPublicNonce(Request $request): bool
192 {
193 // only require a nonce for public requests if user is logged in, this avoids
194 // potential caching issues since unauthenticated requests should nenever be destructive.
195 if (is_user_logged_in() && !wp_verify_nonce($request->_nonce, $request->_action)) {
196 glsr_log()->warning('nonce check failed for public request')->debug($request);
197 return false;
198 }
199 return true;
200 }
201
202 protected function isValidRequest(Request $request): bool
203 {
204 return !empty($request->_action) && empty($request->_ajax_request);
205 }
206
207 protected function post(string $type, Request $request): void
208 {
209 $hook = "route/{$type}/{$request->_action}";
210 glsr()->action('route/request', $request, $hook);
211 if (!$this->isRouted($hook)) {
212 glsr_log()->warning("Unknown {$type} router POST request: {$request->_action}");
213 return;
214 }
215 glsr()->action($hook, $request);
216 }
217
218 protected function sendAjaxError(string $error, Request $request, int $errCode, string $message): void
219 {
220 $data = [
221 'code' => $errCode,
222 'error' => $error,
223 'message' => $message ?: $error,
224 'notices' => '',
225 ];
226 if ('submit-review' === $request->_action) {
227 $data['message'] = __('The form could not be submitted. Please notify the site administrator.', 'site-reviews');
228 }
229 if (glsr()->prefix.'admin_action' === Helper::filterInput('action')) {
230 $message = _x('There was an error', 'admin-text', 'site-reviews');
231 $advice = sprintf(
232 /* translators: %s: a button with the text "reloading" */
233 _x('Try %s the page.', 'try reloading the page (admin-text)', 'site-reviews'),
234 sprintf('<button type="button" class="button-link" onclick="location.reload()">%s</button>', _x('reloading', '(admin-text) e.g. try reloading the page', 'site-reviews')),
235 );
236 glsr(Notice::class)->addError("{$message}: <code>{$error}</code>, {$advice}");
237 $data['notices'] = glsr(Notice::class)->get();
238 }
239 if (429 !== $errCode) {
240 glsr_log()->debug($request->toArray());
241 }
242 wp_send_json_error($data);
243 }
244
245 protected function unguardedAdminActions(): array
246 {
247 return glsr()->filterArray('router/admin/unguarded-actions', [
248 'dismiss-notice',
249 ]);
250 }
251
252 protected function unguardedPublicActions(): array
253 {
254 return glsr()->filterArray('router/public/unguarded-actions', [
255 'approved-review',
256 'fetch-paged-reviews',
257 'submit-review',
258 'verified-review',
259 ]);
260 }
261 }
262