Office 365
Download profile pictures from Microsoft Office 365 and Upload to Interact
The following is an example script that will download profile pictures from Microsoft Office 365 and upload those images to Interact Profiles.
Azure Application Registration
It requires the creation of an Azure Application Registration, with the following permission, this is so that all users can be found via MSGRAPH and the profile image, if available can be downloaded.
When you create the application you will need to record the following:
- Client ID
- Client Secret
- Tenant ID
Interact Profile Source
You will also need to create a General Profile Source in Interact and obtain the Profile Source ID and Profile Source API Key
Script Flow
The script undertakes the following steps:
- Get list of users
- Download Profile Images (where available) for each user to a directory
- Check if the file is the same as the one in the lastUploaded directory, if it is, it removes it from the download directory.
- Uploads all remaining files to Interact using the UMIID
- Moves the uploaded files to the Last Uploaded Folder
- Moves failed files to the Failed Upload Folder
NOTE: This script depends on the UMIUID (External ID) within a users Interact Profile as being the ObjectID of the users profile in EntraID.
Complete Script
# Input Parameters
$downloadFolder = "c:\O365\DownloadedPictures"
$lastUploadFolder = "c:\O365\LastUploadPictures"
$failedUploadFolder = "c:\O365\FailedUploadPictures"
$logpath = "c:\O365\log.txt"
# Interact Upload Parameters
$interactUrl = <Interact Instance URL>"
$profileSourceApiKey = "<Profile Source API Key>"
$profileSourceId = "<Profile Source ID>"
$enableUpload = $true # Set to $false to disable upload
Add-Type -AssemblyName System.Net.Http
# Create directories if they don't exist
if (-not (Test-Path -Path $downloadFolder)) {
New-Item -ItemType Directory -Path $downloadFolder -Force | Out-Null
}
if (-not (Test-Path -Path $lastUploadFolder)) {
New-Item -ItemType Directory -Path $lastUploadFolder -Force | Out-Null
}
if (-not (Test-Path -Path $failedUploadFolder)) {
New-Item -ItemType Directory -Path $failedUploadFolder -Force | Out-Null
}
if (-not (Test-Path -Path (Split-Path -Path $logpath -Parent))) {
New-Item -ItemType Directory -Path (Split-Path -Path $logpath -Parent) -Force | Out-Null
}
# Azure AD App Registration Details
$ClientId = "<Client ID>"
$ClientSecret = "<Client Secret>"
$TenantId = "<Tenant ID>"
# Step 1: Get the access token
$TokenBody = @{
client_id = $ClientId
client_secret = $ClientSecret
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
$TokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Method Post -Body $TokenBody
$AccessToken = $TokenResponse.access_token
# Check if the token was retrieved successfully
if ($null -eq $AccessToken) {
Write-Error "Failed to retrieve access token."
exit
}
# Step 2: Create the authorization header with the access token
$headers = @{
"Authorization" = "Bearer $AccessToken"
"Content-Type" = "application/json"
}
# Step 3: Get list of all users (pagination support for large tenant)
$usersUri = "https://graph.microsoft.com/v1.0/users"
$allUsers = @()
do {
$response = Invoke-RestMethod -Uri $usersUri -Method Get -Headers $headers
$allUsers += $response.value
# Get next page of results if present
$usersUri = $response.'@odata.nextLink'
} while ($usersUri)
# Initialize statistics counters
$stats = @{
TotalUsers = $allUsers.Count
Downloaded = 0
NoProfilePic = 0
DownloadErrors = 0
DuplicatesRemoved = 0
PicturesChanged = 0
Uploaded = 0
UploadFailed = 0
}
# Step 4: Download profile pictures
foreach ($user in $allUsers) {
$userId = $user.id
$path = Join-Path $downloadFolder "$userId.jpg"
try {
# Fetch the user photo (use headers without Content-Type for binary data)
$photoHeaders = @{
"Authorization" = "Bearer $AccessToken"
}
$photoUri = "https://graph.microsoft.com/v1.0/users/$userId/photos/648x648/`$value"
# Use -OutFile to directly save binary data to disk
# ContentType parameter forces treating response as binary
Invoke-RestMethod -Uri $photoUri -Method Get -Headers $photoHeaders -OutFile $path -ErrorAction Stop -ContentType "image/jpeg"
$stats.Downloaded++
$message = "$($user.userPrincipalName) profile picture downloaded"
Write-Output $message
Add-Content -Path $logpath -Value $message
}
catch {
if ($_.Exception.Response.StatusCode.Value__ -eq 404) {
$stats.NoProfilePic++
$message = "$($user.userPrincipalName) has no profile picture"
Write-Output $message
Add-Content -Path $logpath -Value $message
}
else {
$stats.DownloadErrors++
$errorMessage = "Error downloading profile picture for $($user.userPrincipalName): $($_.Exception.Message)"
Write-Error $errorMessage
Add-Content -Path $logpath -Value $errorMessage
}
}
}
# Step 4.5: Remove already uploaded files from download folder (comparing file hashes)
Write-Output "`nChecking for already uploaded files..."
Add-Content -Path $logpath -Value "`n=== Checking for Duplicates ==="
$removedCount = 0
$changedCount = 0
$downloadedFiles = Get-ChildItem $downloadFolder -Filter "*.jpg"
foreach ($downloadedFile in $downloadedFiles) {
$lastUploadedFilePath = Join-Path $lastUploadFolder $downloadedFile.Name
if (Test-Path $lastUploadedFilePath) {
try {
# Compare file hashes to detect if content has changed
$downloadedHash = (Get-FileHash -Path $downloadedFile.FullName -Algorithm SHA256).Hash
$uploadedHash = (Get-FileHash -Path $lastUploadedFilePath -Algorithm SHA256).Hash
if ($downloadedHash -eq $uploadedHash) {
# Files are identical, remove from download folder
Remove-Item -Path $downloadedFile.FullName -Force
$message = "Removed duplicate file (unchanged): $($downloadedFile.Name)"
Write-Output $message
Add-Content -Path $logpath -Value $message
$removedCount++
}
else {
# File content has changed, keep for upload
$message = "Picture changed for: $($downloadedFile.Name) - will be uploaded"
Write-Output $message
Add-Content -Path $logpath -Value $message
$changedCount++
}
}
catch {
$errorMessage = "Failed to compare file $($downloadedFile.Name): $($_.Exception.Message)"
Write-Warning $errorMessage
Add-Content -Path $logpath -Value $errorMessage
}
}
}
$stats.DuplicatesRemoved = $removedCount
$stats.PicturesChanged = $changedCount
$duplicateMessage = "Removed $removedCount duplicate files (unchanged), Found $changedCount pictures that changed"
Write-Output $duplicateMessage
Add-Content -Path $logpath -Value $duplicateMessage
# Upload Functions
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
function ProcessFiles() {
Write-Output "`nStarting upload process..."
Add-Content -Path $logpath -Value "`n=== Upload Process Started ==="
$filesPerMinute = 6
$uploadCounter = 0
$allFiles = Get-ChildItem $downloadFolder -Filter "*.jpg"
$totalFiles = $allFiles.Count
$allFiles | Foreach-Object {
$umiid = $_.BaseName
$baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/umiid/" + $umiid + "/picture"
Write-Output "Uploading $($_.FullName) to $baseUri"
$success = SendFile $baseUri $_.FullName
if ($success) {
$stats.Uploaded++
} else {
$stats.UploadFailed++
}
$uploadCounter++
# Rate limiting: Wait after every 6 uploads to avoid Error 429
if ($uploadCounter -eq $filesPerMinute -and ($stats.Uploaded + $stats.UploadFailed) -lt $totalFiles) {
$rateLimitMessage = "Rate limit: Uploaded $uploadCounter files. Waiting 60 seconds before continuing..."
Write-Output $rateLimitMessage
Add-Content -Path $logpath -Value $rateLimitMessage
Start-Sleep -Seconds 60
$uploadCounter = 0
}
}
$uploadMessage = "Upload complete: $($stats.Uploaded) successful, $($stats.UploadFailed) failed"
Write-Output $uploadMessage
Add-Content -Path $logpath -Value $uploadMessage
}
function SendFile([string]$uri, [string]$path) {
$httpClientHandler = New-Object System.Net.Http.HttpClientHandler
$httpClient = New-Object System.Net.Http.Httpclient $httpClientHandler
$response = $null
$packageFileStream = $null
$success = $false
try {
$packageFileStream = New-Object System.IO.FileStream @($path, [System.IO.FileMode]::Open)
$contentDispositionHeaderValue = New-Object System.Net.Http.Headers.ContentDispositionHeaderValue "form-data"
$contentDispositionHeaderValue.Name = "fileData"
$contentDispositionHeaderValue.FileName = (Split-Path $path -leaf)
$streamContent = New-Object System.Net.Http.StreamContent $packageFileStream
$streamContent.Headers.ContentDisposition = $contentDispositionHeaderValue
$streamContent.Headers.ContentType = New-Object System.Net.Http.Headers.MediaTypeHeaderValue "image/jpeg"
$content = New-Object System.Net.Http.MultipartFormDataContent
$content.Add($streamContent)
$httpClient.DefaultRequestHeaders.Add("X-ApiKey", $profileSourceApiKey)
$response = $httpClient.PostAsync($uri, $content).Result
if ($response.IsSuccessStatusCode) {
$message = "Successfully uploaded: $(Split-Path $path -leaf)"
Write-Output $message
Add-Content -Path $logpath -Value $message
$success = $true
} else {
$errorMessage = "Failed to upload $(Split-Path $path -leaf): $($response.StatusCode)"
Write-Warning $errorMessage
Add-Content -Path $logpath -Value $errorMessage
}
}
catch [Exception] {
$errorMessage = "Error uploading $(Split-Path $path -leaf): $($_.Exception.Message)"
Write-Error $errorMessage
Add-Content -Path $logpath -Value $errorMessage
}
finally {
if ($null -ne $packageFileStream) {
$packageFileStream.Dispose()
}
if ($null -ne $httpClient) {
$httpClient.Dispose()
}
if ($null -ne $response) {
$response.Dispose()
}
}
# Move file to lastUploadFolder after successful upload, or to failedUploadFolder if failed
if ($success) {
try {
$fileName = Split-Path $path -Leaf
$destinationPath = Join-Path $lastUploadFolder $fileName
Move-Item -Path $path -Destination $destinationPath -Force
$moveMessage = "Moved $(Split-Path $path -leaf) to $lastUploadFolder"
Write-Output $moveMessage
Add-Content -Path $logpath -Value $moveMessage
}
catch {
$moveErrorMessage = "Failed to move $(Split-Path $path -leaf): $($_.Exception.Message)"
Write-Warning $moveErrorMessage
Add-Content -Path $logpath -Value $moveErrorMessage
}
} else {
try {
$fileName = Split-Path $path -Leaf
$destinationPath = Join-Path $failedUploadFolder $fileName
Move-Item -Path $path -Destination $destinationPath -Force
$moveMessage = "Moved failed upload $(Split-Path $path -leaf) to $failedUploadFolder"
Write-Output $moveMessage
Add-Content -Path $logpath -Value $moveMessage
}
catch {
$moveErrorMessage = "Failed to move $(Split-Path $path -leaf) to failed folder: $($_.Exception.Message)"
Write-Warning $moveErrorMessage
Add-Content -Path $logpath -Value $moveErrorMessage
}
}
return $success
}
# Step 5: Upload profile pictures to Interact
if ($enableUpload) {
ProcessFiles
} else {
Write-Output "`nUpload disabled. Set `$enableUpload = `$true to enable."
}
# Display Final Statistics
Write-Output "`n==========================================="
Write-Output " FINAL STATISTICS"
Write-Output "==========================================="
Write-Output "Total Users Found: $($stats.TotalUsers)"
Write-Output "Images Downloaded: $($stats.Downloaded)"
Write-Output "No Profile Picture: $($stats.NoProfilePic)"
Write-Output "Download Errors: $($stats.DownloadErrors)"
Write-Output "Duplicates Removed: $($stats.DuplicatesRemoved)"
Write-Output "Pictures Changed: $($stats.PicturesChanged)"
Write-Output "Images Uploaded: $($stats.Uploaded)"
Write-Output "Upload Failed: $($stats.UploadFailed)"
Write-Output "==========================================="
# Write statistics to log
Add-Content -Path $logpath -Value "`n==========================================="
Add-Content -Path $logpath -Value " FINAL STATISTICS"
Add-Content -Path $logpath -Value "==========================================="
Add-Content -Path $logpath -Value "Total Users Found: $($stats.TotalUsers)"
Add-Content -Path $logpath -Value "Images Downloaded: $($stats.Downloaded)"
Add-Content -Path $logpath -Value "No Profile Picture: $($stats.NoProfilePic)"
Add-Content -Path $logpath -Value "Download Errors: $($stats.DownloadErrors)"
Add-Content -Path $logpath -Value "Duplicates Removed: $($stats.DuplicatesRemoved)"
Add-Content -Path $logpath -Value "Pictures Changed: $($stats.PicturesChanged)"
Add-Content -Path $logpath -Value "Images Uploaded: $($stats.Uploaded)"
Add-Content -Path $logpath -Value "Upload Failed: $($stats.UploadFailed)"
Add-Content -Path $logpath -Value "==========================================="
The script can be broken down into sections as detailed below:
Check Directories
# Create directories if they don't exist
if (-not (Test-Path -Path $downloadFolder)) {
New-Item -ItemType Directory -Path $downloadFolder -Force | Out-Null
}
if (-not (Test-Path -Path $lastUploadFolder)) {
New-Item -ItemType Directory -Path $lastUploadFolder -Force | Out-Null
}
if (-not (Test-Path -Path $failedUploadFolder)) {
New-Item -ItemType Directory -Path $failedUploadFolder -Force | Out-Null
}
if (-not (Test-Path -Path (Split-Path -Path $logpath -Parent))) {
New-Item -ItemType Directory -Path (Split-Path -Path $logpath -Parent) -Force | Out-Null
}Checks that the directories exist, if not they are created
MSGraph Function & Steps
Step 1: Get the access token
$TokenBody = @{
client_id = $ClientId
client_secret = $ClientSecret
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
$TokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Method Post -Body $TokenBody
$AccessToken = $TokenResponse.access_token
# Check if the token was retrieved successfully
if ($null -eq $AccessToken) {
Write-Error "Failed to retrieve access token."
exit
}Step 2: Create the authorization header with the access token
$headers = @{
"Authorization" = "Bearer $AccessToken"
"Content-Type" = "application/json"
}Step 3: Get list of all users (pagination support for large tenant)
$usersUri = "https://graph.microsoft.com/v1.0/users"
$allUsers = @()
do {
$response = Invoke-RestMethod -Uri $usersUri -Method Get -Headers $headers
$allUsers += $response.value
# Get next page of results if present
$usersUri = $response.'@odata.nextLink'
} while ($usersUri)
# Initialize statistics counters
$stats = @{
TotalUsers = $allUsers.Count
Downloaded = 0
NoProfilePic = 0
DownloadErrors = 0
DuplicatesRemoved = 0
PicturesChanged = 0
Uploaded = 0
UploadFailed = 0
}
Step 4: Download profile pictures
This section downloads the profile pictures from Office365, it downloads the image as 648x648 to keep this in line with Interact Profile image size.
foreach ($user in $allUsers) {
$userId = $user.id
$path = Join-Path $downloadFolder "$userId.jpg"
try {
# Fetch the user photo (use headers without Content-Type for binary data)
$photoHeaders = @{
"Authorization" = "Bearer $AccessToken"
}
$photoUri = "https://graph.microsoft.com/v1.0/users/$userId/photos/648x648/`$value"
# Use -OutFile to directly save binary data to disk
# ContentType parameter forces treating response as binary
Invoke-RestMethod -Uri $photoUri -Method Get -Headers $photoHeaders -OutFile $path -ErrorAction Stop -ContentType "image/jpeg"
$stats.Downloaded++
$message = "$($user.userPrincipalName) profile picture downloaded"
Write-Output $message
Add-Content -Path $logpath -Value $message
}
catch {
if ($_.Exception.Response.StatusCode.Value__ -eq 404) {
$stats.NoProfilePic++
$message = "$($user.userPrincipalName) has no profile picture"
Write-Output $message
Add-Content -Path $logpath -Value $message
}
else {
$stats.DownloadErrors++
$errorMessage = "Error downloading profile picture for $($user.userPrincipalName): $($_.Exception.Message)"
Write-Error $errorMessage
Add-Content -Path $logpath -Value $errorMessage
}
}
}Step 4.5: Remove already uploaded files from download folder (comparing file hashes)
This checks if the file in the Last Uploaded Folder (if it exists) is the same as the file downloaded, if it is, it is removed to avoid uploading again.
Write-Output "`nChecking for already uploaded files..."
Add-Content -Path $logpath -Value "`n=== Checking for Duplicates ==="
$removedCount = 0
$changedCount = 0
$downloadedFiles = Get-ChildItem $downloadFolder -Filter "*.jpg"
foreach ($downloadedFile in $downloadedFiles) {
$lastUploadedFilePath = Join-Path $lastUploadFolder $downloadedFile.Name
if (Test-Path $lastUploadedFilePath) {
try {
# Compare file hashes to detect if content has changed
$downloadedHash = (Get-FileHash -Path $downloadedFile.FullName -Algorithm SHA256).Hash
$uploadedHash = (Get-FileHash -Path $lastUploadedFilePath -Algorithm SHA256).Hash
if ($downloadedHash -eq $uploadedHash) {
# Files are identical, remove from download folder
Remove-Item -Path $downloadedFile.FullName -Force
$message = "Removed duplicate file (unchanged): $($downloadedFile.Name)"
Write-Output $message
Add-Content -Path $logpath -Value $message
$removedCount++
}
else {
# File content has changed, keep for upload
$message = "Picture changed for: $($downloadedFile.Name) - will be uploaded"
Write-Output $message
Add-Content -Path $logpath -Value $message
$changedCount++
}
}
catch {
$errorMessage = "Failed to compare file $($downloadedFile.Name): $($_.Exception.Message)"
Write-Warning $errorMessage
Add-Content -Path $logpath -Value $errorMessage
}
}
}Interact Upload Steps
Step 5: Upload Profile Pictures to Interact Instance
This section is the bulk of the work, it process the files left in the Download Directory and uploads them to Interact.
if ($enableUpload) {
ProcessFiles
} else {
Write-Output "`nUpload disabled. Set `$enableUpload = `$true to enable."
}
# Display Final Statistics
Write-Output "`n==========================================="
Write-Output " FINAL STATISTICS"
Write-Output "==========================================="
Write-Output "Total Users Found: $($stats.TotalUsers)"
Write-Output "Images Downloaded: $($stats.Downloaded)"
Write-Output "No Profile Picture: $($stats.NoProfilePic)"
Write-Output "Download Errors: $($stats.DownloadErrors)"
Write-Output "Duplicates Removed: $($stats.DuplicatesRemoved)"
Write-Output "Pictures Changed: $($stats.PicturesChanged)"
Write-Output "Images Uploaded: $($stats.Uploaded)"
Write-Output "Upload Failed: $($stats.UploadFailed)"
Write-Output "==========================================="
# Write statistics to log
Add-Content -Path $logpath -Value "`n==========================================="
Add-Content -Path $logpath -Value " FINAL STATISTICS"
Add-Content -Path $logpath -Value "==========================================="
Add-Content -Path $logpath -Value "Total Users Found: $($stats.TotalUsers)"
Add-Content -Path $logpath -Value "Images Downloaded: $($stats.Downloaded)"
Add-Content -Path $logpath -Value "No Profile Picture: $($stats.NoProfilePic)"
Add-Content -Path $logpath -Value "Download Errors: $($stats.DownloadErrors)"
Add-Content -Path $logpath -Value "Duplicates Removed: $($stats.DuplicatesRemoved)"
Add-Content -Path $logpath -Value "Pictures Changed: $($stats.PicturesChanged)"
Add-Content -Path $logpath -Value "Images Uploaded: $($stats.Uploaded)"
Add-Content -Path $logpath -Value "Upload Failed: $($stats.UploadFailed)"
Add-Content -Path $logpath -Value "==========================================="
This section calls, the function
Process Files to Interact Profile Source
This section of the code, will upload the downloaded file to the relevant profile in Interact, by matching the user using the UMI ID (External ID). This is the routine that will check the directory for files and send the file to SendFile function.
function ProcessFiles() {
Write-Output "`nStarting upload process..."
Add-Content -Path $logpath -Value "`n=== Upload Process Started ==="
$filesPerMinute = 6
$uploadCounter = 0
$allFiles = Get-ChildItem $downloadFolder -Filter "*.jpg"
$totalFiles = $allFiles.Count
$allFiles | Foreach-Object {
$umiid = $_.BaseName
$baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/umiid/" + $umiid + "/picture"
Write-Output "Uploading $($_.FullName) to $baseUri"
$success = SendFile $baseUri $_.FullName
if ($success) {
$stats.Uploaded++
} else {
$stats.UploadFailed++
}
$uploadCounter++
# Rate limiting: Wait after every 6 uploads to avoid Error 429
if ($uploadCounter -eq $filesPerMinute -and ($stats.Uploaded + $stats.UploadFailed) -lt $totalFiles) {
$rateLimitMessage = "Rate limit: Uploaded $uploadCounter files. Waiting 60 seconds before continuing..."
Write-Output $rateLimitMessage
Add-Content -Path $logpath -Value $rateLimitMessage
Start-Sleep -Seconds 60
$uploadCounter = 0
}
}
$uploadMessage = "Upload complete: $($stats.Uploaded) successful, $($stats.UploadFailed) failed"
Write-Output $uploadMessage
Add-Content -Path $logpath -Value $uploadMessage
}Which then calls the SendFile function to do the upload.
Send File to Interact Profile Source
This routine is the actual function that does the uploading of the files. It has control in the function to only allow 6 uploads per minute as per the threshold requirements of the Profile Picture process.
Depending on success or failure, the file is moved to the relevant folder.
Using Username
As mentioned in the script above, this is based on using the UMI ID of a users profile in Interact, which is expected to match the Entra ID Object ID of the profile in Entra.
The script can be changed to download the files using the User Principal Name (UPN) when that matches the Username in interact.
To do this the following lines need to change
$userId = $user.id
$path = Join-Path $downloadFolder "$userId.jpg"In the above the $userId is being set to the Object ID, to change it to the UPN, change the lines to
$userId = $user.userPrincipalName
$path = Join-Path $downloadFolder "$userId.jpg"You will also need to change the Upload URL from
$umiid = $_.BaseName
$baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/umiid/" + $umiid + "/picture"To
$username = $_.BaseName
$baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/username/" + $username + "/picture"This is an example script only.
Updated about 7 hours ago
