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

@ -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.
*