PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.0
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 1.5.5 All 68 releases
← All changes | app/Http/Controllers/CustomerController.php +210 -19 1.10.52.4.0 View file →
@@ -3,10 +3,12 @@
3 3 namespace FluentSupport\App\Http\Controllers;
4 4
5 5 use FluentCrm\App\Models\Subscriber;
6 6 use FluentSupport\App\Models\Customer;
7 -use FluentSupport\Framework\Request\Request;
7 +use FluentSupport\Framework\Http\Request\Request;
8 8 use FluentSupport\App\Services\AvatarUploder;
9 +use FluentSupport\App\Services\Helper;
10 +use FluentSupport\Framework\Support\Arr;
9 11
10 12 /**
11 13 * CustomerController class for REST API
12 14 * This class is responsible for getting data for all request related to customer
@@ -16,8 +18,13 @@
16 18 */
17 19 class CustomerController extends Controller
18 20 {
19 21 /**
22 + * Maximum number of customers accepted by a single bulk-delete request.
23 + */
24 + const BULK_DELETE_LIMIT = 100;
25 +
26 + /**
20 27 * index method will return the list of customers
21 28 * @param Request $request
22 29 * @param Customer $customer
23 30 * @return array
@@ -30,9 +37,9 @@
30 37 }
31 38
32 39 public function customerField (Request $request,Customer $customer, $customer_id) {
33 40
34 - $userID = intval($request->get('user_id'));
41 + $userID = $request->getSafe('user_id', 'intval');
35 42 return[
36 43 'customerField' => $customer->getCustomerField($customer_id,$userID)
37 44 ];
38 45 }
@@ -47,9 +54,12 @@
47 54 * @return array
48 55 */
49 56 public function getCustomer(Request $request, Customer $customer, $customer_id)
50 57 {
51 - return $customer->getCustomer($customer_id, $request->getSafe('with',null,[]));
58 + $with = $request->get('with', null);
59 + $with = is_array($with) ? array_map('sanitize_key', $with) : [];
60 +
61 + return $customer->getCustomer($customer_id, $with);
52 62 }
53 63
54 64 /**
55 65 * Create method will create new customer
@@ -59,15 +69,59 @@
59 69 * @throws \FluentSupport\Framework\Validator\ValidationException
60 70 */
61 71 public function create(Request $request, Customer $customer)
62 72 {
63 - $this->validate($request->get(), [
64 - 'email' => 'required|email|unique:fs_persons'
73 + // Define expected fields with their sanitizers
74 + $fields = [
75 + 'id' => 'intval',
76 + 'customer_id' => 'intval',
77 + 'avatar' => 'esc_url_raw',
78 + 'person_type' => 'sanitize_text_field',
79 + 'hash' => 'sanitize_text_field',
80 + 'description' => 'sanitize_text_field',
81 + 'photo' => 'esc_url_raw',
82 + 'email' => 'sanitize_email',
83 + 'first_name' => 'sanitize_text_field',
84 + 'last_name' => 'sanitize_text_field',
85 + 'title' => 'sanitize_text_field',
86 + 'user_id' => 'intval',
87 + 'remote_uid' => 'sanitize_text_field',
88 + 'status' => 'sanitize_text_field',
89 + 'address_line_1' => 'sanitize_textarea_field',
90 + 'address_line_2' => 'sanitize_textarea_field',
91 + 'city' => 'sanitize_text_field',
92 + 'state' => 'sanitize_text_field',
93 + 'zip' => 'sanitize_text_field',
94 + 'country' => 'sanitize_text_field',
95 + 'note' => 'sanitize_textarea_field',
96 + 'ip_address' => 'sanitize_text_field',
97 + 'last_ip_address' => 'sanitize_text_field',
98 + ];
99 +
100 + $data = $this->sanitizeRequestData($request, $fields);
101 +
102 + $data = $this->validate($data, [
103 + 'email' => 'required|email|unique:fs_persons',
104 + 'first_name' => 'required',
105 + 'last_name' => 'nullable|string',
106 + 'title' => 'nullable|string',
107 + 'user_id' => 'nullable|integer',
108 + 'remote_uid' => 'nullable|string',
109 + 'status' => 'nullable|string',
110 + 'address_line_1' => 'nullable|string',
111 + 'address_line_2' => 'nullable|string',
112 + 'city' => 'nullable|string',
113 + 'state' => 'nullable|string',
114 + 'zip' => 'nullable|string',
115 + 'country' => 'nullable|string',
116 + 'note' => 'nullable|string',
117 + 'ip_address' => 'nullable|string',
118 + 'last_ip_address' => 'nullable|string',
65 119 ]);
66 120
67 121 return [
68 122 'message' => __('Customer has been added', 'fluent-support'),
69 - 'customer' => $customer->createCustomer($request->get())
123 + 'customer' => $customer->createCustomer($data)
70 124 ];
71 125 }
72 126
73 127 /**
@@ -79,11 +133,54 @@
79 133 * @throws \FluentSupport\Framework\Validator\ValidationException
80 134 */
81 135 public function update(Request $request, Customer $customer, $customer_id)
82 136 {
83 - $data = $this->validate($request->get(), [
137 + // Sanitize only allowed fields and also sanitize any extra fields from hooks
138 + $fields = [
139 + 'id' => 'intval',
140 + 'customer_id' => 'intval',
141 + 'avatar' => 'esc_url_raw',
142 + 'person_type' => 'sanitize_text_field',
143 + 'hash' => 'sanitize_text_field',
144 + 'description' => 'sanitize_text_field',
145 + 'photo' => 'esc_url_raw',
146 + 'email' => 'sanitize_email',
147 + 'first_name' => 'sanitize_text_field',
148 + 'last_name' => 'sanitize_text_field',
149 + 'title' => 'sanitize_text_field',
150 + 'user_id' => 'intval',
151 + 'remote_uid' => 'sanitize_text_field',
152 + 'status' => 'sanitize_text_field',
153 + 'address_line_1' => 'sanitize_textarea_field',
154 + 'address_line_2' => 'sanitize_textarea_field',
155 + 'city' => 'sanitize_text_field',
156 + 'state' => 'sanitize_text_field',
157 + 'zip' => 'sanitize_text_field',
158 + 'country' => 'sanitize_text_field',
159 + 'note' => 'sanitize_textarea_field',
160 + 'ip_address' => 'sanitize_text_field',
161 + 'last_ip_address' => 'sanitize_text_field',
162 + ];
163 +
164 + $data = $this->sanitizeRequestData($request, $fields);
165 +
166 + $data = $this->validate($data, [
84 167 'email' => 'required|email',
85 - 'first_name' => 'required'
168 + 'first_name' => 'required',
169 + 'last_name' => 'nullable|string',
170 + 'title' => 'nullable|string',
171 + 'user_id' => 'nullable|integer',
172 + 'remote_uid' => 'nullable|string',
173 + 'status' => 'nullable|string',
174 + 'address_line_1' => 'nullable|string',
175 + 'address_line_2' => 'nullable|string',
176 + 'city' => 'nullable|string',
177 + 'state' => 'nullable|string',
178 + 'zip' => 'nullable|string',
179 + 'country' => 'nullable|string',
180 + 'note' => 'nullable|string',
181 + 'ip_address' => 'nullable|string',
182 + 'last_ip_address' => 'nullable|string',
86 183 ]);
87 184
88 185 try {
89 186 return [
@@ -91,9 +188,9 @@
91 188 'customer' => $customer->updateCustomer($customer_id, $data)
92 189 ];
93 190 } catch (\Exception $e) {
94 191 return $this->sendError([
95 - 'message' => $e->getMessage(),
192 + 'message' => Helper::getSafeErrorMessage($e),
96 193 'errors' => [
97 194 'email' => [
98 195 'unique' => __('Email address has been assigned to other customer', 'fluent-support'),
99 196 ]
@@ -114,8 +211,37 @@
114 211 return $customer->deleteCustomer($customer_id);
115 212 }
116 213
117 214 /**
215 + * bulkDelete method will delete multiple customers and all their tickets
216 + * @param Request $request
217 + * @param Customer $customer
218 + * @return array
219 + */
220 + public function bulkDelete(Request $request, Customer $customer)
221 + {
222 + // Get and sanitize customer_ids before validation
223 + $customerIds = $request->get('customer_ids', []);
224 + $customerIds = is_array($customerIds) ? array_map('intval', $customerIds) : [];
225 +
226 + // Filter out any zero values (from invalid input)
227 + $customerIds = array_filter($customerIds, function ($id) {
228 + return $id > 0;
229 + });
230 +
231 + $customerIds = array_values(array_unique($customerIds));
232 +
233 + // Each id fans out into a full cascade delete (tickets, conversations,
234 + // attachments), so an unbounded batch means an unbounded request.
235 + $this->validate(['customer_ids' => $customerIds], [
236 + 'customer_ids' => 'required|array|min:1|max:' . self::BULK_DELETE_LIMIT,
237 + 'customer_ids.*' => 'required|integer|exists:fs_persons,id'
238 + ]);
239 +
240 + return $customer->bulkDeleteCustomers($customerIds);
241 + }
242 +
243 + /**
118 244 * addOrUpdateProfileImage method will update a customer avatar
119 245 * For a successful upload it's required to send file object, customer id and the user type(customer)
120 246 * @param Request $request
121 247 * @return array
@@ -125,9 +251,9 @@
125 251 try {
126 252 return $avatarUploder->addOrUpdateProfileImage($request->files(), $request->getSafe('customer_id', 'intval'), 'customer');
127 253 } catch (\Exception $e) {
128 254 return $this->sendError([
129 - 'message' => $e->getMessage(),
255 + 'message' => Helper::getSafeErrorMessage($e),
130 256 ],
131 257 $e->getCode()
132 258 );
133 259 }
@@ -135,16 +261,20 @@
135 261
136 262 /**
137 263 * resetAvatar method will restore a customer avatar
138 264 * For a successful upload it's required to send file object, customer id and the user type(customer)
139 - * @param Request $request
140 - * @param $id
265 + *
266 + * No Customer type-hint here: route-model binding resolves inside the
267 + * permission callback, before any policy runs, which lets unauthenticated
268 + * callers probe customer ID existence (FS-PERM-001). Resolve after auth.
269 + * @param int|string $customer
141 270 * @return array
142 271 */
143 - public function resetAvatar(Customer $customer, $customer_id)
272 + public function resetAvatar($customer)
144 273 {
145 274 try {
146 - $customer->restoreAvatar($customer, $customer_id);
275 + $customer = Customer::findOrFail((int) $customer);
276 + $customer->restoreAvatar();
147 277
148 278 return [
149 279 'message' => __('Customer avatar reset to gravatar default', 'fluent-support'),
150 280 ];
@@ -149,9 +279,9 @@
149 279 'message' => __('Customer avatar reset to gravatar default', 'fluent-support'),
150 280 ];
151 281 } catch (\Exception $e) {
152 282 return [
153 - 'message' => $e->getMessage()
283 + 'message' => Helper::getSafeErrorMessage($e)
154 284 ];
155 285 }
156 286 }
157 287
@@ -156,17 +286,42 @@
156 286 }
157 287
158 288 public function searchContact(Request $request)
159 289 {
160 - $search = $request->getSafe('search', 'sanitize_text_field');
290 + $search = trim($request->getSafe('search', 'sanitize_text_field'));
291 +
292 + // '*' is a WP_User_Query wildcard and survives sanitize_text_field, so a
293 + // lone '*' would list every user on the site. Stripping it leaves
294 + // WP_User_Query doing an exact match.
295 + $search = trim(str_replace('*', '', $search));
296 +
161 297 if (!$search) {
162 298 return $this->sendError([
163 - 'message' => 'Please provide search string'
299 + 'message' => __('Please provide search string', 'fluent-support')
164 300 ]);
165 301 }
166 302
167 303 $isEmail = is_email($search);
168 304
305 + // Require a meaningful prefix so the endpoint can't be walked one letter
306 + // at a time. Emails are matched exactly, so they need no minimum.
307 + if (!$isEmail && mb_strlen($search) < 3) {
308 + return $this->sendError([
309 + 'message' => __('Please provide at least 3 characters to search', 'fluent-support')
310 + ]);
311 + }
312 +
313 + if (Helper::hitRateLimit('fs_contact_search_' . get_current_user_id(), 60, 5 * MINUTE_IN_SECONDS)) {
314 + return $this->sendError([
315 + 'message' => __('Too many contact searches. Please try again in a few minutes.', 'fluent-support')
316 + ], 429);
317 + }
318 +
319 + // '%' and '_' are LIKE wildcards for the customer and CRM scopes below.
320 + // Escape rather than strip: underscores are legitimate in emails.
321 + global $wpdb;
322 + $likeSearch = $wpdb->esc_like($search);
323 +
169 324 // search the existing customers first
170 325 if ($isEmail) {
171 326 $customers = Customer::select(['first_name', 'last_name', 'email', 'id', 'user_id'])
172 327 ->where('email', $search)
@@ -172,9 +327,9 @@
172 327 ->where('email', $search)
173 328 ->get();
174 329 } else {
175 330 $customers = Customer::select(['first_name', 'last_name', 'email', 'id', 'user_id'])
176 - ->searchBy($search)
331 + ->searchBy($likeSearch)
177 332 ->limit(10)
178 333 ->get();
179 334 }
180 335
@@ -196,9 +351,9 @@
196 351 ->select(['first_name', 'last_name', 'email', 'id', 'user_id'])
197 352 ->get();
198 353 } else {
199 354
200 - $contacts = \FluentCrm\App\Models\Subscriber::searchBy($search)
355 + $contacts = \FluentCrm\App\Models\Subscriber::searchBy($likeSearch)
201 356 ->select(['first_name', 'last_name', 'email', 'id', 'user_id'])
202 357 ->limit(10)
203 358 ->get();
204 359 }
@@ -245,6 +400,42 @@
245 400 'data' => [],
246 401 'is_email' => $isEmail
247 402 ];
248 403
404 + }
405 +
406 + /**
407 + * Sanitize request data for given fields. Uses Request::getSafe for known fields
408 + * and falls back to sanitize_text_field for any other keys present in the raw request
409 + * (useful when hooks inject extra data).
410 + *
411 + * @param Request $request
412 + * @param array $fieldsMap associative array field => sanitizer callable name
413 + * @return array
414 + */
415 + private function sanitizeRequestData(Request $request, array $fieldsMap)
416 + {
417 + $sanitized = [];
418 +
419 + // Use getSafe for known fields
420 + foreach ($fieldsMap as $field => $sanitizer) {
421 + $sanitized[$field] = $request->getSafe($field, $sanitizer);
422 + }
423 +
424 + // Now sanitize any other incoming keys to avoid unsanitized data
425 + $raw = $request->get();
426 + foreach ($raw as $key => $value) {
427 + if (array_key_exists($key, $sanitized)) {
428 + continue;
429 + }
430 +
431 + if (is_array($value)) {
432 + $sanitized[$key] = array_map('sanitize_text_field', $value);
433 + } else {
434 + // Fallback sanitizer for unknown fields
435 + $sanitized[$key] = is_string($value) ? sanitize_text_field($value) : $value;
436 + }
437 + }
438 +
439 + return $sanitized;
249 440 }
250 441 }