PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
fluent-support / app / Hooks / Handlers / ExternalPages.php

ExternalPages.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Hooks/Handlers/ExternalPages.php

373 lines 14.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Hooks\Handlers;
4
5 use FluentSupport\App\Models\Attachment;
6 use FluentSupport\App\Models\Ticket;
7 use FluentSupport\App\Services\EmailClaimService;
8 use FluentSupport\App\Services\Helper;
9 use FluentSupport\Framework\Support\Arr;
10
11 /**
12 * Public ticket, attachment and email-claim pages.
13 */
14 class ExternalPages
15 {
16 public function route()
17 {
18 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
19 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
20 if ($requestMethod !== 'GET') {
21 wp_die(esc_html__('Invalid request method', 'fluent-support'), esc_html__('Method Not Allowed', 'fluent-support'), ['response' => 405]);
22 }
23
24 $this->checkRateLimit();
25 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
26 if (!isset($_REQUEST['fs_view'])) {
27 wp_die(esc_html__('Missing required parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
28 }
29
30 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
31 $route = isset($_REQUEST['fs_view']) ? sanitize_text_field(wp_unslash($_REQUEST['fs_view'])) : '';
32
33 if (empty($route)) {
34 wp_die(esc_html__('Missing required parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
35 }
36
37 $methodMaps = [
38 'ticket' => 'handleTicketView',
39 'email_claim' => 'handleEmailClaim'
40 ];
41
42 if (isset($methodMaps[$route])) {
43 $this->verifyPublicEndpointSecurity($route);
44 $this->{$methodMaps[$route]}();
45 } else {
46 wp_die(esc_html__('Invalid route', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
47 }
48 }
49
50 /**
51 * Request or confirm moving the support address to the WP account's email.
52 *
53 * `fs_claim_action=send` sends mail, so it needs a nonce. `fs_claim=<token>` is
54 * the signed link from that mail and still requires the requesting account.
55 *
56 * @return void
57 */
58 public function handleEmailClaim()
59 {
60 $baseUrl = Helper::getPortalBaseUrl();
61
62 if (!$baseUrl) {
63 wp_die(esc_html__('Invalid route', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
64 }
65
66 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing only; the send branch verifies a nonce and the confirm branch verifies a signature
67 $action = isset($_GET['fs_claim_action']) ? sanitize_text_field(wp_unslash($_GET['fs_claim_action'])) : '';
68
69 if ($action === 'send') {
70 $this->handleEmailClaimRequest();
71 }
72
73 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the token is a signed secret delivered to the address being claimed
74 $token = isset($_GET['fs_claim']) ? sanitize_text_field(wp_unslash($_GET['fs_claim'])) : '';
75
76 if (!$token) {
77 $this->redirectToPortal('invalid');
78 }
79
80 if (!get_current_user_id()) {
81 // The mailbox alone isn't enough proof, so require login too.
82 $returnUrl = add_query_arg([
83 'fs_view' => 'email_claim',
84 'fs_claim' => $token
85 ], $baseUrl);
86
87 wp_safe_redirect(wp_login_url($returnUrl));
88 exit;
89 }
90
91 $resolved = EmailClaimService::resolveClaim($token);
92
93 if ($resolved['status'] !== 'ok') {
94 $this->redirectToPortal($resolved['status']);
95 }
96
97 EmailClaimService::apply($resolved['customer'], $resolved['email']);
98
99 $this->redirectToPortal('confirmed');
100 }
101
102 /**
103 * @return void
104 */
105 protected function handleEmailClaimRequest()
106 {
107 if (!get_current_user_id()) {
108 $this->redirectToPortal('invalid');
109 }
110
111 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '';
112
113 if (!wp_verify_nonce($nonce, 'fs_email_claim_send')) {
114 $this->redirectToPortal('invalid');
115 }
116
117 $divergence = EmailClaimService::getDivergence();
118
119 if (!$divergence) {
120 $this->redirectToPortal('nothing_to_do');
121 }
122
123 $error = EmailClaimService::issue($divergence);
124
125 $this->redirectToPortal($error ?: 'sent');
126 }
127
128 /**
129 * @param string $status
130 * @return void
131 */
132 protected function redirectToPortal($status)
133 {
134 wp_safe_redirect(add_query_arg('fs_claim_result', $status, Helper::getPortalBaseUrl()));
135 exit;
136 }
137
138 public function handleTicketView()
139 {
140 if (!Helper::isPublicSignedTicketEnabled()) {
141 $this->handleInvalidTicket();
142 } else {
143 $this->handleValidTicket();
144 }
145 }
146
147 /**
148 * Serve an attachment by its `fst_file` hash and signature.
149 *
150 * @return void
151 * @since 3.2.0
152 */
153 public function view_attachment()
154 {
155 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
156 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
157 if ($requestMethod !== 'GET') {
158 wp_die(esc_html__('Invalid request method', 'fluent-support'), esc_html__('Method Not Allowed', 'fluent-support'), ['response' => 405]);
159 }
160
161 $this->checkRateLimit();
162
163 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
164 if (!isset($_REQUEST['fst_file'])) {
165 wp_die(esc_html__('Missing required parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
166 }
167
168 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
169 $attachmentHash = isset($_REQUEST['fst_file']) ? sanitize_text_field(wp_unslash($_REQUEST['fst_file'])) : '';
170
171 if (empty($attachmentHash)) {
172 wp_die(esc_html__('Invalid attachment hash.', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
173 }
174
175 $attachment = $this->getAttachmentByHash($attachmentHash);
176
177 if (!$attachment) {
178 wp_die(esc_html__('Invalid attachment hash.', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
179 }
180
181 // Inline (pasted) images skip the signature because they are already embedded in emails.
182 if ($attachment->status !== 'inline' && !$this->validateAttachmentSignature($attachment)) {
183 $dieMessage = esc_html__('Sorry, Your secure sign is invalid, Please reload the previous page and get new signed url', 'fluent-support');
184 wp_die(esc_html($dieMessage), esc_html__('Forbidden', 'fluent-support'), ['response' => 403]);
185 }
186
187 if ('local' !== $attachment->driver) {
188 $fileUrl = apply_filters('fluent_support/external_attachment_url', $attachment->full_url, $attachment);
189 if (!empty($fileUrl)) {
190 $this->redirectToExternalAttachment($fileUrl);
191 } else {
192 die('File could not be found');
193 }
194 }
195
196 if (!file_exists($attachment->file_path)) {
197 die('File could not be found');
198 }
199 $this->serveLocalAttachment($attachment);
200 }
201
202 private function getAttachmentByHash($attachmentHash)
203 {
204 return Attachment::where('file_hash', $attachmentHash)->first();
205 }
206
207 private function validateAttachmentSignature($attachment)
208 {
209 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
210 if (!isset($_REQUEST['secure_sign'])) {
211 return false;
212 }
213
214 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
215 $secureSign = isset($_REQUEST['secure_sign']) ? sanitize_text_field(wp_unslash($_REQUEST['secure_sign'])) : '';
216
217 if (empty($secureSign)) {
218 return false;
219 }
220
221 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
222 $expires = isset($_REQUEST['expires']) ? sanitize_text_field(wp_unslash($_REQUEST['expires'])) : '';
223
224 if ($expires === '') {
225 // Legacy URL signed for the current UTC hour only.
226 return hash_equals(Attachment::legacySignature($attachment->id, gmdate('YmdH')), $secureSign);
227 }
228
229 if (!ctype_digit($expires) || (int) $expires < time()) {
230 return false;
231 }
232
233 return hash_equals(Attachment::signature($attachment->id, (int) $expires), $secureSign);
234 }
235
236 private function handleInvalidTicket()
237 {
238 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
239 if (!isset($_REQUEST['ticket_id'])) {
240 wp_die(esc_html__('Missing ticket ID parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
241 }
242
243 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
244 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
245
246 if ($ticketId <= 0) {
247 wp_die(esc_html__('Invalid ticket ID', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
248 }
249
250 $ticket = Ticket::wherePublicIdentifier($ticketId)->first();
251
252 if (!$ticket) {
253 $this->showInvalidPortalMessage();
254 } else {
255 $this->redirectToTicketView($ticket);
256 }
257 }
258
259 private function handleValidTicket()
260 {
261 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
262 if (!isset($_REQUEST['support_hash']) || !isset($_REQUEST['ticket_id'])) {
263 wp_die(esc_html__('Missing required parameters', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
264 }
265
266 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
267 $ticketHash = isset($_REQUEST['support_hash']) ? sanitize_text_field(wp_unslash($_REQUEST['support_hash'])) : '';
268 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
269 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
270
271 if (empty($ticketHash) || !preg_match('/^[a-zA-Z0-9]+$/', $ticketHash)) {
272 wp_die(esc_html__('Invalid ticket hash format', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
273 }
274
275 if ($ticketId <= 0) {
276 wp_die(esc_html__('Invalid ticket ID', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
277 }
278
279 $ticket = Ticket::where('hash', $ticketHash)
280 ->wherePublicIdentifier($ticketId)
281 ->first();
282
283 if (!$ticket) {
284 $this->showInvalidPortalMessage();
285 } elseif (get_current_user_id()) {
286 $this->redirectToTicketView($ticket);
287 }
288 // Logged-out visitors stay on the signed URL; the portal app reads the hash from it.
289 }
290
291 private function showInvalidPortalMessage()
292 {
293 echo '<h3 style="text-align: center; margin: 50px 0;">' . esc_html__('Invalid Support Portal URL', 'fluent-support') . '</h3>';
294 die();
295 }
296
297 private function redirectToTicketView($ticket)
298 {
299 $redirectUrl = Helper::getTicketViewUrl($ticket);
300 $this->redirectToExternalAttachment($redirectUrl);
301 }
302
303 private function redirectToExternalAttachment($redirectUrl)
304 {
305 // Not wp_safe_redirect: remote attachments (Drive, Dropbox) live on other hosts.
306 wp_redirect($redirectUrl, 307);
307 exit();
308 }
309
310 private function serveLocalAttachment($attachment)
311 {
312 $file_path = realpath($attachment->file_path);
313 $uploads = wp_upload_dir();
314 $uploads_dir = realpath($uploads['basedir']);
315
316 if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
317 wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
318 return;
319 }
320
321 ob_get_clean();
322 $original_user_agent = ini_get('user_agent');
323 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Temporary change for file serving, restored immediately after
324 ini_set('user_agent', 'Fluent Support/' . FLUENT_SUPPORT_VERSION . '; ' . esc_url(get_bloginfo('url')));
325
326 header("Content-Type: " . esc_attr($attachment->file_type));
327 header("Content-Disposition: inline; filename=\"" . esc_attr($attachment->title) . "\"");
328
329 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Direct file serving required for attachment download
330 readfile($file_path);
331
332 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Restoring original value
333 ini_set('user_agent', $original_user_agent);
334 die();
335 }
336
337 private function verifyPublicEndpointSecurity($route)
338 {
339 switch ($route) {
340 case 'ticket':
341 // support_hash is only required when signed ticket links are enabled.
342 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
343 if (!isset($_REQUEST['ticket_id'])) {
344 wp_die(esc_html__('Missing ticket ID parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
345 }
346 break;
347
348 default:
349 break;
350 }
351 }
352
353 /**
354 * 30 requests per minute per IP on the public endpoints.
355 */
356 private function checkRateLimit()
357 {
358 $ip = Helper::getIp();
359 $transient_key = 'fs_rate_limit_' . wp_hash($ip);
360 $requests = get_transient($transient_key);
361
362 if ($requests === false) {
363 set_transient($transient_key, 1, 60);
364 } else {
365 $requests++;
366 if ($requests > 30) {
367 wp_die(esc_html__('Rate limit exceeded. Please try again later.', 'fluent-support'), esc_html__('Too Many Requests', 'fluent-support'), ['response' => 429]);
368 }
369 set_transient($transient_key, $requests, 60);
370 }
371 }
372 }
373