Keep profile photos and fields in line with Microsoft 365 on every run
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run

- Photo versions are compared on every sync run via Graph $batch
  (20 users per request); only changed photos are downloaded, the old
  file is deleted and the avatar URL changes. Photos deleted in
  Microsoft 365 are deleted in WordPress. Graph errors never delete a
  photo. Download limit per run (500) with deferral to the next run.
- Switching the photo sync off removes all stored photos; deselected
  m365_* profile fields are removed from the profiles.
- A user's photo is deleted together with the user (delete_user hook).
- Dry run reports photo changes without downloading.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-23 16:40:45 +00:00
parent 4edf20bc45
commit 9f77e9027f
11 changed files with 589 additions and 339 deletions

View file

@ -75,6 +75,7 @@ class M365_Login_Sync {
add_filter( 'determine_current_user', array( $this, 'drop_disabled_session' ), 100 );
add_filter( 'pre_get_avatar_data', array( $this, 'avatar_data' ), 10, 2 );
add_action( 'delete_user', array( $this, 'delete_photo' ) );
if ( is_admin() ) {
add_filter( 'manage_users_columns', array( $this, 'users_column' ) );
@ -394,7 +395,7 @@ class M365_Login_Sync {
$linked = $this->linked_users();
$seen = array();
$pending = array(); // Deactivations/deletions, applied after the safety check.
$photos = 0;
$photo_of = array(); // oid => WP_User whose photo is kept in sync.
$photo_on = array_key_exists( 'photo', $this->selected_attributes() );
foreach ( $people as $person ) {
@ -404,11 +405,18 @@ class M365_Login_Sync {
$result = $this->sync_person( $person, $linked, $memberships );
if ( is_array( $result ) ) {
$pending[] = $result;
} elseif ( $result instanceof WP_User && $photo_on && ! $this->dry ) {
$photos += $this->maybe_sync_photo( $result, $oid, $photos );
} elseif ( $result instanceof WP_User ) {
$photo_of[ $oid ] = $result;
}
}
// Profile photos: new, changed and removed photos, or cleanup when the photo sync was switched off.
if ( $photo_on ) {
$this->sync_photos( $photo_of );
} else {
$this->remove_all_photos();
}
// 3. Linked accounts that were not part of the directory listing.
foreach ( $linked as $oid => $user_id ) {
if ( isset( $seen[ $oid ] ) ) {
@ -776,6 +784,22 @@ class M365_Login_Sync {
}
}
// Fields that are no longer selected are removed from the profile (only the plugin's own m365_* keys).
$selected = $this->selected_attributes();
foreach ( $labels as $key => $attribute ) {
$target = isset( $attribute['target'] ) ? (string) $attribute['target'] : '';
if ( isset( $selected[ $key ] ) || 0 !== strpos( $target, 'm365_' ) ) {
continue;
}
if ( '' !== (string) get_user_meta( $user->ID, $target, true ) ) {
if ( ! $this->dry ) {
delete_user_meta( $user->ID, $target );
}
/* translators: %s: profile field */
$changes[] = sprintf( __( '%s removed', 'm365-login' ), $attribute['label'] );
}
}
if ( $fields && ! $this->dry ) {
$fields['ID'] = $user->ID;
$result = wp_update_user( $fields );
@ -996,8 +1020,7 @@ class M365_Login_Sync {
$this->log( 'info', sprintf( __( '%1$s: account deleted (%2$s).', 'm365-login' ), $user->user_email, $action['label'] ) );
$this->count( 'deleted' );
if ( ! $this->dry ) {
$this->delete_photo( $user->ID );
wp_delete_user( $user->ID, $reassign );
wp_delete_user( $user->ID, $reassign ); // The delete_user hook removes the photo.
}
return;
}
@ -1133,89 +1156,182 @@ class M365_Login_Sync {
/* ------------------------------------------------------------------ */
/**
* Refreshes the profile photo when it was not checked recently.
* Brings the stored photos in line with Microsoft 365: downloads new and changed
* photos and deletes photos that were removed in Microsoft 365.
*
* @param WP_User $user User.
* @param string $oid Object ID.
* @param int $done_so_far Photo checks done in this run.
* @return int 1 when Graph was asked, 0 otherwise.
* Photo versions are compared on every run (20 users per Graph batch request);
* only changed photos are downloaded.
*
* @param WP_User[] $users oid => user.
*/
private function maybe_sync_photo( $user, $oid, $done_so_far ) {
private function sync_photos( $users ) {
/**
* Maximum number of profile photo checks per sync run (the rest follows in later runs).
*
* @param int $limit Limit.
*/
if ( $done_so_far >= (int) apply_filters( 'm365_login_sync_photo_limit', 200 ) ) {
return 0;
}
$stored = get_user_meta( $user->ID, self::META_PHOTO, true );
$stored = is_array( $stored ) ? $stored : array();
/**
* Seconds between two photo checks of the same user.
* Minimum number of seconds between two photo checks of the same user (0 = every run).
*
* @param int $interval Interval.
*/
$interval = (int) apply_filters( 'm365_login_sync_photo_interval', 20 * HOUR_IN_SECONDS );
if ( ! empty( $stored['checked'] ) && time() - (int) $stored['checked'] < $interval ) {
return 0;
}
$interval = (int) apply_filters( 'm365_login_sync_photo_interval', 0 );
$info = $this->graph->photo_info( $oid );
if ( is_wp_error( $info ) ) {
/* translators: 1: e-mail address, 2: error message */
$this->log( 'warning', sprintf( __( '%1$s: profile photo could not be read: %2$s', 'm365-login' ), $user->user_email, $info->get_error_message() ) );
return 1;
}
/**
* Maximum number of photo downloads per sync run (the rest follows in later runs).
*
* @param int $limit Limit.
*/
$limit = (int) apply_filters( 'm365_login_sync_photo_limit', 500 );
if ( null === $info ) {
if ( ! empty( $stored['file'] ) ) {
$this->delete_photo( $user->ID );
/* translators: %s: e-mail address */
$this->log( 'info', sprintf( __( '%s: profile photo removed.', 'm365-login' ), $user->user_email ) );
$this->count( 'photos' );
$check = array();
foreach ( $users as $oid => $user ) {
$stored = $this->stored_photo( $user->ID );
if ( $interval > 0 && ! empty( $stored['checked'] ) && time() - (int) $stored['checked'] < $interval ) {
continue;
}
$check[ $oid ] = $user;
}
if ( empty( $check ) ) {
return;
}
$versions = $this->graph->photo_versions( array_keys( $check ) );
$downloads = 0;
$deferred = 0;
foreach ( $check as $oid => $user ) {
$version = array_key_exists( $oid, $versions ) ? $versions[ $oid ] : new WP_Error( 'graph_photo', 'No answer.' );
$stored = $this->stored_photo( $user->ID );
// Never delete anything because of an error only a clear "no photo" removes it.
if ( is_wp_error( $version ) ) {
/* translators: 1: e-mail address, 2: error message */
$this->log( 'warning', sprintf( __( '%1$s: profile photo could not be read: %2$s', 'm365-login' ), $user->user_email, $version->get_error_message() ) );
continue;
}
if ( null === $version ) {
if ( ! empty( $stored['file'] ) ) {
$this->remove_photo( $user );
} elseif ( ! $this->dry ) {
update_user_meta( $user->ID, self::META_PHOTO, array( 'checked' => time() ) );
}
continue;
}
if ( ! empty( $stored['file'] ) && isset( $stored['etag'] ) && $stored['etag'] === $version && file_exists( self::photo_path( $stored['file'] ) ) ) {
if ( ! $this->dry ) {
$stored['checked'] = time();
update_user_meta( $user->ID, self::META_PHOTO, $stored );
}
continue;
}
// New or changed photo.
if ( $this->dry ) {
/* translators: %s: e-mail address */
$this->log( 'info', sprintf( __( '%s: profile photo updated.', 'm365-login' ), $user->user_email ) );
$this->count( 'photos' );
continue;
}
if ( $downloads >= $limit ) {
++$deferred;
continue;
}
++$downloads;
$bytes = $this->graph->photo_bytes( $oid );
if ( null === $bytes ) {
if ( ! empty( $stored['file'] ) ) {
$this->remove_photo( $user );
}
continue;
}
if ( is_wp_error( $bytes ) || '' === $bytes || strlen( $bytes ) > self::PHOTO_MAX ) {
/* translators: %s: e-mail address */
$this->log( 'warning', sprintf( __( '%s: profile photo could not be downloaded.', 'm365-login' ), $user->user_email ) );
continue;
}
$file = $this->store_photo( $user->ID, $oid, $version, $bytes );
if ( '' === $file ) {
/* translators: %s: e-mail address */
$this->log( 'warning', sprintf( __( '%s: profile photo is not a valid image or could not be saved.', 'm365-login' ), $user->user_email ) );
continue;
}
if ( ! empty( $stored['file'] ) && $stored['file'] !== $file ) {
wp_delete_file( self::photo_path( $stored['file'] ) );
}
update_user_meta(
$user->ID,
self::META_PHOTO,
array(
'file' => $file,
'etag' => $version,
'checked' => time(),
)
);
/* translators: %s: e-mail address */
$this->log( 'info', sprintf( __( '%s: profile photo updated.', 'm365-login' ), $user->user_email ) );
$this->count( 'photos' );
}
if ( $deferred ) {
/* translators: %d: number of photos */
$this->log( 'info', sprintf( _n( '%d changed profile photo will be downloaded in the next run (download limit per run reached).', '%d changed profile photos will be downloaded in the next run (download limit per run reached).', $deferred, 'm365-login' ), $deferred ) );
}
}
/**
* Removes the stored photo of a user whose photo was deleted in Microsoft 365.
*
* @param WP_User $user User.
*/
private function remove_photo( $user ) {
if ( ! $this->dry ) {
$this->delete_photo( $user->ID );
update_user_meta( $user->ID, self::META_PHOTO, array( 'checked' => time() ) );
return 1;
}
/* translators: %s: e-mail address */
$this->log( 'info', sprintf( __( '%s: profile photo removed.', 'm365-login' ), $user->user_email ) );
$this->count( 'photos' );
}
if ( ! empty( $stored['file'] ) && isset( $stored['etag'] ) && $stored['etag'] === $info['etag'] && file_exists( self::photo_path( $stored['file'] ) ) ) {
$stored['checked'] = time();
update_user_meta( $user->ID, self::META_PHOTO, $stored );
return 1;
}
$bytes = $this->graph->photo_bytes( $info['path'] );
if ( is_wp_error( $bytes ) || '' === $bytes || strlen( $bytes ) > self::PHOTO_MAX ) {
/* translators: %s: e-mail address */
$this->log( 'warning', sprintf( __( '%s: profile photo could not be downloaded.', 'm365-login' ), $user->user_email ) );
return 1;
}
$file = $this->store_photo( $user->ID, $oid, $info['etag'], $bytes );
if ( '' === $file ) {
/* translators: %s: e-mail address */
$this->log( 'warning', sprintf( __( '%s: profile photo is not a valid image or could not be saved.', 'm365-login' ), $user->user_email ) );
return 1;
}
if ( ! empty( $stored['file'] ) && $stored['file'] !== $file ) {
wp_delete_file( self::photo_path( $stored['file'] ) );
}
update_user_meta(
$user->ID,
self::META_PHOTO,
/**
* Deletes every stored photo (the photo sync was switched off).
*/
private function remove_all_photos() {
$users = get_users(
array(
'file' => $file,
'etag' => $info['etag'],
'checked' => time(),
'meta_key' => self::META_PHOTO, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_compare' => 'EXISTS',
'fields' => array( 'ID' ),
'number' => -1,
)
);
/* translators: %s: e-mail address */
$this->log( 'info', sprintf( __( '%s: profile photo updated.', 'm365-login' ), $user->user_email ) );
$this->count( 'photos' );
return 1;
$removed = 0;
foreach ( $users as $row ) {
$stored = $this->stored_photo( (int) $row->ID );
if ( ! empty( $stored['file'] ) ) {
++$removed;
}
if ( ! $this->dry ) {
$this->delete_photo( (int) $row->ID );
}
}
if ( $removed ) {
/* translators: %d: number of photos */
$this->log( 'info', sprintf( _n( 'Profile photo sync is off: %d stored photo removed.', 'Profile photo sync is off: %d stored photos removed.', $removed, 'm365-login' ), $removed ) );
$this->count( 'photos', $removed );
}
}
/**
* Stored photo record of a user.
*
* @param int $user_id User ID.
* @return array
*/
private function stored_photo( $user_id ) {
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
return is_array( $stored ) ? $stored : array();
}
/**
@ -1273,11 +1389,11 @@ class M365_Login_Sync {
}
/**
* Deletes a user's stored photo.
* Deletes a user's stored photo (also hooked to user deletion).
*
* @param int $user_id User ID.
*/
private function delete_photo( $user_id ) {
public function delete_photo( $user_id ) {
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
if ( is_array( $stored ) && ! empty( $stored['file'] ) && 0 === strpos( $stored['file'], self::PHOTO_DIR . '/' ) ) {
wp_delete_file( self::photo_path( $stored['file'] ) );