| @@ -18,8 +18,13 @@ | ||
| 18 | 18 | */ |
| 19 | 19 | class CustomerController extends Controller |
| 20 | 20 | { |
| 21 | 21 | /** |
| 22 | + * Maximum number of customers accepted by a single bulk-delete request. | |
| 23 | + */ | |
| 24 | + const BULK_DELETE_LIMIT = 100; | |
| 25 | + | |
| 26 | + /** | |
| 22 | 27 | * index method will return the list of customers |
| 23 | 28 | * @param Request $request |
| 24 | 29 | * @param Customer $customer |
| 25 | 30 | * @return array |
| @@ -222,10 +227,14 @@ | ||
| 222 | 227 | $customerIds = array_filter($customerIds, function ($id) { |
| 223 | 228 | return $id > 0; |
| 224 | 229 | }); |
| 225 | 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. | |
| 226 | 235 | $this->validate(['customer_ids' => $customerIds], [ |
| 227 | - 'customer_ids' => 'required|array|min:1', | |
| 236 | + 'customer_ids' => 'required|array|min:1|max:' . self::BULK_DELETE_LIMIT, | |
| 228 | 237 | 'customer_ids.*' => 'required|integer|exists:fs_persons,id' |
| 229 | 238 | ]); |
| 230 | 239 | |
| 231 | 240 | return $customer->bulkDeleteCustomers($customerIds); |
| @@ -252,15 +261,19 @@ | ||
| 252 | 261 | |
| 253 | 262 | /** |
| 254 | 263 | * resetAvatar method will restore a customer avatar |
| 255 | 264 | * For a successful upload it's required to send file object, customer id and the user type(customer) |
| 256 | - * @param Request $request | |
| 257 | - * @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 | |
| 258 | 270 | * @return array |
| 259 | 271 | */ |
| 260 | - public function resetAvatar(Customer $customer) | |
| 272 | + public function resetAvatar($customer) | |
| 261 | 273 | { |
| 262 | 274 | try { |
| 275 | + $customer = Customer::findOrFail((int) $customer); | |
| 263 | 276 | $customer->restoreAvatar(); |
| 264 | 277 | |
| 265 | 278 | return [ |
| 266 | 279 | 'message' => __('Customer avatar reset to gravatar default', 'fluent-support'), |
| @@ -273,9 +286,15 @@ | ||
| 273 | 286 | } |
| 274 | 287 | |
| 275 | 288 | public function searchContact(Request $request) |
| 276 | 289 | { |
| 277 | - $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 | + | |
| 278 | 297 | if (!$search) { |
| 279 | 298 | return $this->sendError([ |
| 280 | 299 | 'message' => __('Please provide search string', 'fluent-support') |
| 281 | 300 | ]); |
| @@ -282,8 +301,27 @@ | ||
| 282 | 301 | } |
| 283 | 302 | |
| 284 | 303 | $isEmail = is_email($search); |
| 285 | 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 | + | |
| 286 | 324 | // search the existing customers first |
| 287 | 325 | if ($isEmail) { |
| 288 | 326 | $customers = Customer::select(['first_name', 'last_name', 'email', 'id', 'user_id']) |
| 289 | 327 | ->where('email', $search) |
| @@ -289,9 +327,9 @@ | ||
| 289 | 327 | ->where('email', $search) |
| 290 | 328 | ->get(); |
| 291 | 329 | } else { |
| 292 | 330 | $customers = Customer::select(['first_name', 'last_name', 'email', 'id', 'user_id']) |
| 293 | - ->searchBy($search) | |
| 331 | + ->searchBy($likeSearch) | |
| 294 | 332 | ->limit(10) |
| 295 | 333 | ->get(); |
| 296 | 334 | } |
| 297 | 335 | |
| @@ -313,9 +351,9 @@ | ||
| 313 | 351 | ->select(['first_name', 'last_name', 'email', 'id', 'user_id']) |
| 314 | 352 | ->get(); |
| 315 | 353 | } else { |
| 316 | 354 | |
| 317 | - $contacts = \FluentCrm\App\Models\Subscriber::searchBy($search) | |
| 355 | + $contacts = \FluentCrm\App\Models\Subscriber::searchBy($likeSearch) | |
| 318 | 356 | ->select(['first_name', 'last_name', 'email', 'id', 'user_id']) |
| 319 | 357 | ->limit(10) |
| 320 | 358 | ->get(); |
| 321 | 359 | } |