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
fluent-support / app / Services / ProfileInfoService.php

ProfileInfoService.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.0, at app/Services/ProfileInfoService.php

376 lines 14.5 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\Services;
4
5 use FluentSupport\App\Models\Activity;
6 use FluentSupport\App\Models\Customer;
7 use FluentSupport\App\Models\Ticket;
8
9 class ProfileInfoService
10 {
11 public static function getProfileExtraWidgets( $customer )
12 {
13 $widgets = [];
14 /*
15 * Filter customer profile widgets
16 *
17 * @since v1.0.0
18 * @param array $widgets
19 * @param object|array $customer
20 *
21 * @return void
22 */
23 $widgets = apply_filters('fluent_support/customer_extra_widgets', $widgets, $customer);
24 return $widgets;
25 }
26
27 // This method is linked with 'profile_update' action & it will trigger when user update profile
28 public function onWPProfileUpdate($userId, $userOldData, $userUpdatedData)
29 {
30 if (!$userId || !is_array($userUpdatedData)) {
31 return false;
32 }
33
34 // Matching on user_id only is what keeps this safe: a WordPress account
35 // can never reach a customer row it is not already linked to, so this
36 // can only ever rewrite the requester's own record.
37 $customers = Customer::where('user_id', $userId)->get();
38
39 if (!$customers || count($customers) === 0) {
40 return false;
41 }
42
43 $keys = ['first_name', 'last_name', 'user_email'];
44
45 if (array_diff_key(array_flip($keys), $userUpdatedData)) {
46 return false;
47 }
48
49 // wp_insert_user() takes slashed data, and profile_update hands that same
50 // array straight on, so a name like O'Brien arrives as O\'Brien. Written
51 // through unchanged it reaches the customer record with a literal
52 // backslash, which is what the customer then sees in the portal and on
53 // every notification addressed to them.
54 $userUpdatedData = wp_unslash($userUpdatedData);
55
56 $email = $userUpdatedData['user_email'];
57
58 // Any plugin calling wp_update_user() for any reason fires this hook, so
59 // most of the time the account address has not moved at all. Reading the
60 // pending-change meta and running a capability check on every one of
61 // those is work for nothing.
62 //
63 // It also keeps the administrator path honest. Without this, an
64 // administrator editing somebody's first name would move the support
65 // address onto whatever unverified address the account happened to be
66 // carrying, because the check below only compares the customer record to
67 // the account. Authority is granted for the change the actor actually
68 // made, not for a divergence somebody else created earlier.
69 // Unslashed on both sides. wp_insert_user() slashes the previous address
70 // on purpose -- "Slash current user email to compare it later with
71 // slashed new user email" -- so comparing it against the unslashed new
72 // one reads an address like o'brien@example.com as changed on every
73 // update, and an administrator editing only a name would move the
74 // support address onto it.
75 $previousAccountEmail = is_object($userOldData) && isset($userOldData->user_email)
76 ? wp_unslash($userOldData->user_email)
77 : '';
78
79 // An unknown previous address falls through to the full check rather
80 // than silently skipping it.
81 $accountEmailMoved = !$previousAccountEmail
82 || !self::isSameEmail($previousAccountEmail, $email);
83
84 // Only a proven change may move the address that support notifications
85 // and signed ticket links are delivered to. Names are cosmetic and are
86 // always synced.
87 $changeReason = $accountEmailMoved ? self::provenEmailChangeReason($userId, $email) : '';
88 $emailIsProven = (bool) $changeReason;
89
90 foreach ($customers as $customer) {
91 $data = [
92 'first_name' => $userUpdatedData['first_name'],
93 'last_name' => $userUpdatedData['last_name'],
94 ];
95
96 $previousEmail = $customer->email;
97
98 // Never move this row onto an address another customer already
99 // holds. Duplicate emails make every later email-based match
100 // ambiguous, and mail piping would then thread onto whichever row
101 // happens to have the lower id.
102 $emailMoved = $emailIsProven
103 && !self::isSameEmail($previousEmail, $email)
104 && !self::isEmailHeldByAnotherCustomer($email, $customer->id);
105
106 if ($emailMoved) {
107 $data['email'] = $email;
108 }
109
110 $customer->fill($data);
111 $customer->save();
112
113 if ($emailMoved) {
114 self::onProvenEmailChange($customer, $previousEmail, $changeReason);
115 }
116 }
117
118 return true;
119 }
120
121 /**
122 * How the email change being applied right now was authorised, or an empty
123 * string when it was not.
124 *
125 * Two paths count. The account holder can prove the new address by opening
126 * WordPress's confirmation link, and somebody with authority over the
127 * account can change it on their behalf. Everything else, which is most
128 * things, leaves the customer's contact address where it is.
129 *
130 * @param int $userId
131 * @param string $newEmail
132 * @return string 'verified', 'administrator', or ''
133 */
134 public static function provenEmailChangeReason($userId, $newEmail)
135 {
136 if (self::isWpConfirmedEmailChange($userId, $newEmail)) {
137 return 'verified';
138 }
139
140 if (self::isAdminAuthorizedEmailChange($userId)) {
141 return 'administrator';
142 }
143
144 return '';
145 }
146
147 /**
148 * Whether an administrator is changing this account's address on its
149 * owner's behalf.
150 *
151 * Authority over the WordPress account is the test. Someone who may edit
152 * that account may also move the address Fluent Support writes to, on the
153 * basis that the customer record's address is derived from the account in
154 * the first place.
155 *
156 * Worth knowing what that admits on a WooCommerce store. WooCommerce grants
157 * edit_users to shop_manager through a user_has_cap filter and then narrows
158 * it, in wc_modify_map_meta_cap(), to accounts holding the 'customer' role,
159 * which is the population Fluent Support serves. A shop manager therefore
160 * passes this check for any customer, whether or not they hold a single
161 * Fluent Support permission, and can redirect that customer's support
162 * notifications. Narrowing this to holders of fst_sensitive_data, the
163 * permission CustomerPolicy uses to gate editing a customer record, would
164 * close that; it is a one-line change here.
165 *
166 * The address is authorised but still unproven, which is why this path
167 * rotates ticket hashes exactly as the verified one does, so links already
168 * delivered to the previous inbox stop working.
169 *
170 * Excluding the actor's own account matters: an administrator changing their
171 * own address goes through the same unverified endpoints as anybody else and
172 * gets no special treatment.
173 *
174 * @param int $userId
175 * @return bool
176 */
177 public static function isAdminAuthorizedEmailChange($userId)
178 {
179 $actorId = get_current_user_id();
180
181 if (!$actorId || $actorId === (int) $userId) {
182 return false;
183 }
184
185 return current_user_can('edit_user', (int) $userId);
186 }
187
188 /**
189 * Whether the email change being applied to this user right now went through
190 * WordPress's own click-through confirmation.
191 *
192 * wp-admin/user-edit.php calls wp_update_user() and only deletes the
193 * _new_email meta afterwards, so the pending record is still readable while
194 * profile_update fires. The record on its own proves nothing: submitting the
195 * profile form writes it without any click, so an unverified change made
196 * through REST could arrive with a matching address already sitting in meta.
197 * Only the hash coming back in the request shows the link was opened from
198 * the inbox the mail was delivered to.
199 *
200 * @param int $userId
201 * @param string $newEmail
202 * @return bool
203 */
204 public static function isWpConfirmedEmailChange($userId, $newEmail)
205 {
206 // Mirrors the condition core applies the confirmed change under.
207 // wp-admin/user-edit.php runs the confirmation branch only when
208 // IS_PROFILE_PAGE is truthy, and that constant is defined nowhere but
209 // profile.php and user-edit.php, where it means "the user is editing
210 // their own account". Requiring it here means a replayed hash cannot be
211 // honoured from REST, WP-CLI, or an administrator editing somebody else.
212 if (!defined('IS_PROFILE_PAGE') || !IS_PROFILE_PAGE) {
213 return false;
214 }
215
216 $pending = get_user_meta($userId, '_new_email', true);
217
218 if (!is_array($pending) || empty($pending['hash']) || empty($pending['newemail'])) {
219 return false;
220 }
221
222 if (!self::isSameEmail($pending['newemail'], $newEmail)) {
223 return false;
224 }
225
226 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- this compares a secret delivered to the user's own inbox; it does not act on request state
227 $submitted = isset($_GET['newuseremail'])
228 ? sanitize_text_field(wp_unslash($_GET['newuseremail']))
229 : '';
230
231 return $submitted && hash_equals($pending['hash'], $submitted);
232 }
233
234 /**
235 * Apply the consequences of a customer's contact address moving to one that
236 * has been proven.
237 *
238 * Shared by every path that is allowed to move an address -- WordPress's own
239 * confirmation, an administrator with rights over the account, and the
240 * portal claim flow in EmailClaimService -- so a proven move has exactly one
241 * set of consequences however it was proven.
242 *
243 * @param \FluentSupport\App\Models\Customer $customer
244 * @param string $previousEmail
245 * @param string $reason 'verified', 'administrator', 'claimed' or 'agent'
246 * @return void
247 */
248 public static function onProvenEmailChange($customer, $previousEmail, $reason = 'verified')
249 {
250 // Signed ticket links already delivered to the previous inbox keep
251 // authorizing read, reply, close and reopen on these tickets until the
252 // hash they carry stops matching.
253 $tickets = Ticket::where('customer_id', $customer->id)->get();
254
255 foreach ($tickets as $ticket) {
256 $ticket->hash = bin2hex(random_bytes(16));
257 $ticket->save();
258 }
259
260 Activity::create([
261 'event_type' => 'fluent_support/customer_email_changed',
262 'person_id' => $customer->id,
263 'person_type' => 'customer',
264 'object_id' => $customer->id,
265 'object_type' => 'customer',
266 'description' => sprintf(
267 self::emailChangeDescription($reason),
268 $previousEmail,
269 $customer->email
270 )
271 ]);
272
273 /*
274 * Fires after a customer's contact address moves to a newly proven one.
275 *
276 * @since v2.4.1
277 * @param \FluentSupport\App\Models\Customer $customer
278 * @param string $previousEmail
279 * @param string $reason
280 */
281 do_action('fluent_support/customer_email_verified_change', $customer, $previousEmail, $reason);
282 }
283
284 /**
285 * Activity log wording for each way an address can be proven.
286 *
287 * @param string $reason
288 * @return string A sprintf format taking the previous and new addresses
289 */
290 protected static function emailChangeDescription($reason)
291 {
292 if ($reason === 'administrator') {
293 // translators: 1: previous email address, 2: new email address
294 return __('Contact email changed from %1$s to %2$s by an administrator editing the WordPress account.', 'fluent-support');
295 }
296
297 if ($reason === 'agent') {
298 // translators: 1: previous email address, 2: new email address
299 return __('Contact email changed from %1$s to %2$s by a support agent editing the customer.', 'fluent-support');
300 }
301
302 if ($reason === 'claimed') {
303 // translators: 1: previous email address, 2: new email address
304 return __('Contact email changed from %1$s to %2$s, confirmed by the account holder from the support portal.', 'fluent-support');
305 }
306
307 // translators: 1: previous email address, 2: new email address
308 return __('Contact email changed from %1$s to %2$s, confirmed through WordPress email verification.', 'fluent-support');
309 }
310
311 /**
312 * @param string $left
313 * @param string $right
314 * @return bool
315 */
316 protected static function isSameEmail($left, $right)
317 {
318 return strtolower(trim((string) $left)) === strtolower(trim((string) $right));
319 }
320
321 /**
322 * Adopt customer rows that already carry this address but are not linked to
323 * a WordPress account yet.
324 *
325 * Registration is one of the few moments where an address may be treated as
326 * belonging to the account: WordPress refuses to register an address that
327 * another user already holds, and the credentials it sends go to that inbox.
328 * A later email change carries no such proof, which is why binding is done
329 * here rather than on every profile update.
330 *
331 * @param int $userId
332 * @return void
333 */
334 public function onWPUserRegister($userId)
335 {
336 $user = $userId ? get_user_by('ID', $userId) : false;
337
338 if (!$user || !$user->user_email) {
339 return;
340 }
341
342 /*
343 * Filter whether a newly registered WordPress account adopts unlinked
344 * customer rows that already carry its email address. Sites that let
345 * visitors register with a self-chosen password may want this off.
346 *
347 * @since v2.4.1
348 * @param bool $shouldLink
349 * @param \WP_User $user
350 */
351 if (!apply_filters('fluent_support/link_customer_on_user_register', true, $user)) {
352 return;
353 }
354
355 Customer::where('email', $user->user_email)
356 ->unclaimed()
357 ->update(['user_id' => $user->ID]);
358 }
359
360 /**
361 * @param string $email
362 * @param int $customerId
363 * @return bool
364 */
365 protected static function isEmailHeldByAnotherCustomer($email, $customerId)
366 {
367 if (!$email) {
368 return true;
369 }
370
371 return (bool) Customer::where('email', $email)
372 ->where('id', '!=', $customerId)
373 ->first();
374 }
375 }
376