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

@ -640,7 +640,7 @@ class M365_Login_Admin {
</label>
<?php endforeach; ?>
</div>
<p class="description"><?php esc_html_e( 'Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are checked about once a day per user.', 'm365-login' ); ?></p>
<p class="description"><?php esc_html_e( 'Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are compared on every run: changed photos are downloaded again, photos deleted in Microsoft 365 are deleted in WordPress too. Fields and photos you deselect here are removed from the profiles on the next run (first and last name and display name stay).', 'm365-login' ); ?></p>
</div>
<div class="m365-card">

View file

@ -277,52 +277,111 @@ class M365_Login_Graph {
}
/**
* Metadata of a user's profile photo (prefers the 240×240 rendition).
* Runs up to 20 GET requests in one Graph JSON batch.
*
* @param string[] $paths Request key => path relative to the v1.0 base.
* @return array|WP_Error Request key => array( 'status' => int, 'body' => mixed ).
*/
public function batch_get( $paths ) {
$requests = array();
foreach ( array_values( $paths ) as $i => $path ) {
$requests[] = array(
'id' => (string) $i,
'method' => 'GET',
'url' => $path,
);
}
$keys = array_keys( $paths );
if ( empty( $requests ) ) {
return array();
}
if ( count( $requests ) > 20 ) {
return new WP_Error( 'graph_batch_size', 'A Graph batch holds at most 20 requests.' );
}
$result = $this->request( 'POST', '/$batch', array( 'requests' => $requests ) );
if ( is_wp_error( $result ) ) {
return $result;
}
$out = array();
foreach ( isset( $result['responses'] ) && is_array( $result['responses'] ) ? $result['responses'] : array() as $response ) {
$i = isset( $response['id'] ) ? (int) $response['id'] : -1;
if ( isset( $keys[ $i ] ) ) {
$out[ $keys[ $i ] ] = array(
'status' => isset( $response['status'] ) ? (int) $response['status'] : 0,
'body' => isset( $response['body'] ) ? $response['body'] : null,
);
}
}
return $out;
}
/**
* Profile photo versions of several users (one batch request per 20 users).
*
* @param string[] $oids User object IDs.
* @return array oid => etag string, null (user has no photo) or WP_Error (could not be checked).
*/
public function photo_versions( $oids ) {
$out = array();
foreach ( array_chunk( array_values( array_filter( $oids, array( 'M365_Login_Settings', 'is_guid' ) ) ), 20 ) as $chunk ) {
$paths = array();
foreach ( $chunk as $oid ) {
$paths[ $oid ] = '/users/' . rawurlencode( strtolower( $oid ) ) . '/photo';
}
$responses = $this->batch_get( $paths );
foreach ( $chunk as $oid ) {
if ( is_wp_error( $responses ) ) {
$out[ $oid ] = $responses;
continue;
}
$response = isset( $responses[ $oid ] ) ? $responses[ $oid ] : array(
'status' => 0,
'body' => null,
);
if ( 404 === $response['status'] ) {
$out[ $oid ] = null;
} elseif ( 200 === $response['status'] && is_array( $response['body'] ) ) {
$etag = isset( $response['body']['@odata.mediaEtag'] ) ? (string) $response['body']['@odata.mediaEtag'] : '';
$out[ $oid ] = '' !== $etag ? $etag : md5( (string) wp_json_encode( $response['body'] ) );
} else {
$code = isset( $response['body']['error']['code'] ) ? (string) $response['body']['error']['code'] : 'HTTP ' . $response['status'];
$out[ $oid ] = new WP_Error( 'graph_photo', $code, array( 'status' => $response['status'] ) );
}
}
}
return $out;
}
/**
* Downloads a user's photo (240×240 rendition, else the original).
*
* @param string $oid User object ID.
* @return array|null|WP_Error array( 'path' => photo path, 'etag' => string ), null when the user has no photo.
* @return string|null|WP_Error Binary image data, null when the user has no photo.
*/
public function photo_info( $oid ) {
public function photo_bytes( $oid ) {
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
}
$base = '/users/' . rawurlencode( strtolower( $oid ) );
foreach ( array( $base . '/photos/240x240', $base . '/photo' ) as $path ) {
$meta = $this->request( 'GET', $path );
if ( is_wp_error( $meta ) ) {
if ( self::is_not_found( $meta ) ) {
continue;
}
return $meta;
foreach ( array( $base . '/photos/240x240/$value', $base . '/photo/$value' ) as $path ) {
$response = $this->raw_request( 'GET', $path, null, array( 'Accept' => 'image/*' ) );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
if ( 200 === $code ) {
return $body;
}
if ( 404 !== $code ) {
return $this->error_from( $code, json_decode( $body, true ) );
}
$etag = isset( $meta['@odata.mediaEtag'] ) ? (string) $meta['@odata.mediaEtag'] : '';
return array(
'path' => $path,
'etag' => '' !== $etag ? $etag : md5( (string) wp_json_encode( $meta ) ),
);
}
return null;
}
/**
* Downloads photo bytes.
*
* @param string $path Photo path returned by photo_info().
* @return string|WP_Error Binary image data.
*/
public function photo_bytes( $path ) {
$response = $this->raw_request( 'GET', $path . '/$value', null, array( 'Accept' => 'image/*' ) );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
if ( 200 !== $code ) {
return $this->error_from( $code, json_decode( $body, true ) );
}
return $body;
}
/**
* Searches groups by display name.
*

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'] ) );