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
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:
parent
4edf20bc45
commit
9f77e9027f
11 changed files with 589 additions and 339 deletions
|
|
@ -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'] ) );
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue