How to Bulk Load and Update Profile Avatar Pictures with Powershell - V2
- Subscribe to RSS Feed
- Mark as New
- Mark as Read
- Bookmark
- Subscribe
- Printer Friendly Page
- Report Inappropriate Content
I created a blog post in 2020 explaining how to bulk load and sync Canvas Avatar images using Powershell.
Since then, I've refined the code quite substantially. Here is a new version which is probably slightly more user friendly and easier to manage.
Please note I've cobbled this together from code modules which I maintain in our environment. I've copy/pasted most of the function calls which would be in our Canvas.psm1 code module. I think the script should work fine, but there may be a few bugs due to the fact that the format presented here isn't exactly how we use it at our school. I'm just sharing it in a user-friendly format.
Quite a bit of the information in my original blog post (https://community.canvaslms.com/t5/Canvas-Developers-Group/How-to-bulk-load-and-update-avatar-profil...) is probably still relevant, so it might pay to have a quick read of that before trying to integrate this script in your IT environment.
My original version had a lot of processing concerned with checking whether or not a sync was required, and image lifetime checks etc. This one is simpler - it just does a complete sync of all user images, and over-writes any existing images it finds. So, you could run this once or twice a week and just clobber anything which has been uploaded in the past.
Please note that this process DELETES each user's profile pictures folder every time it runs, then re-creates it when it uploads a new image. I found that this is the only reliable way of over-writing an image with the same name where the actual image might have changed. There might be a better way to do this, if I find one I'll modify this code to improve it.
If anyone has questions, please feel free to ask. Or let me know how you get on with implementing this in your environment.
$SCRIPT:canvasApiToken = "<YOUR_TOKEN_HERE>"
$SCRIPT:canvasApiHeader = @{"Authorization"="Bearer " + $SCRIPT:CanvasApiToken}
$SCRIPT:canvasApiUrl = 'https://<YOUR_DOMAIN>.instructure.com:443/api/v1'
$SCRIPT:imageFolder = "<PATH_TO_IMAGE_FOLDER>"
$SCRIPT:imageFileExtension = "jpg"
$SCRIPT:avatarUrlTemplate = 'https://<YOUR_DOMAIN>.instructure.com.images/thumbnails/.+/.+'
$SCRIPT:avatarUrlPath = 'https://<YOUR_DOMAIN>.instructure.com/images/thumbnails/'
$SCRIPT:avatarDefaultUrl = 'https://<YOUR_DOMAIN>.instructure.com/images/messages/avatar-50.png'
$SCRIPT:validAvatarUrlPattern = "^https://<YOUR_DOMAIN>.instructure.com.images/thumbnails/\d+/{0}$"
$SCRIPT:canvasProfileFolderName = 'profile pictures'
$SCRIPT:canvasProfileFolderParentPath = 'my files'
$SCRIPT:canvasProfileFolderPath = '{0}/{1}' -f $SCRIPT:canvasProfileFolderParentPath, $SCRIPT:canvasProfileFolderName
$SCRIPT:imageFileContentType = 'image/jpeg'
###########################################################################
# FUNCTIONS
###########################################################################
Function Invoke-CanvasApiRequest (
$uri,
$method = 'GET',
$contentType,
$inFile,
$body,
$form
) {
<#
.PARAMETER $uri
The api end point URI. Note that this should NOT include the URI base e.g. server name + version.
In other words, it should look like this: '/accounts/1/roles/'
#>
if($uri[0] -NE '/') { $uri = '/{0}' -f $uri}
$uri = '{0}{1}' -f $SCRIPT:canvasApiUrl, $uri
$params = @{
Uri = $uri
Method = $method
Headers = $SCRIPT:canvasApiHeader
FollowRelLink = $true
}
if(-NOT [String]::IsNullOrWhiteSpace($contentType)) { $params.ContentType = $contentType}
if(-NOT [String]::IsNullOrWhiteSpace($body)) { $params.Body = $body}
if(-NOT [String]::IsNullOrWhiteSpace($form)) { $params.Form = $form}
if(-NOT [String]::IsNullOrWhiteSpace($inFile)) { $params.InFile = $inFile}
Write-Debug ('{0}: {1}' -f $method.toUpper(), $uri)
Write-Debug ('PARAMS: {0}' -f [PsCustomObject]$params)
if($params.keys -CONTAINS 'form' -AND $params.Form -NE $null) {
Write-Debug ('FORM: {0}' -f [PsCustomObject]$params)
}
$response = Invoke-RestMethod @params
<# Remove pagination. #>
$response = $response | ForEach-Object {$_}
return $response
}
Function Get-CanvasUser(
$canvasId
) {
$uri = "/users/{0}" -f $canvasId
$user = Invoke-CanvasApiRequest -Uri $uri
return $user
}
Function Get-UserInfo (
[int] $userCanvasId
) {
$user = Get-CanvasUser -canvasId $userCanvasId
$imageFileName = "$($user.sis_user_id).$imageFileExtension"
$imageFilePath = "$imageFolder\$imageFileName"
Add-Member `
-InputObject $user `
-NotePropertyName ImageFileName `
-NotePropertyValue $imageFileName `
-Force
Add-Member `
-InputObject $user `
-NotePropertyName ImageFolder `
-NotePropertyValue $imageFolder `
-Force
Add-Member `
-InputObject $user `
-NotePropertyName ImageFilePath `
-NotePropertyValue $imageFilePath `
-Force
<# The information for a user returned by the Canvas API includes the URL of that
user's avatar image.
If user's avatar has a valid URL, we extract file ID and UUID from it and attach them
to the user object which is returned.
Otherwise leave these fields empty. #>
if ($user.avatar_url -match $SCRIPT:avatarUrlTemplate) {
$avatarFileId = $user.avatar_url.Replace($SCRIPT:avatarUrlPath, '').split('/')[0]
$avatarFileUuid = $user.avatar_url.Replace($SCRIPT:avatarUrlPath, '').split('/')[1]
} else {
$avatarFileId = $null
$avatarFileUuid = $null
}
Add-Member `
-InputObject $user `
-NotePropertyName AvatarFileId `
-NotePropertyValue $avatarFileId `
-Force
Add-Member `
-InputObject $user `
-NotePropertyName AvatarFileUuid `
-NotePropertyValue $avatarFileUuid `
-Force
return $user
}
Function Get-CanvasUserFolders (
[int] $userCanvasId,
[string] $folderName,
[string] $parentFolderPath
) {
<#
Be careful of case sensitivity, I have a feeling that Canvas API is
case sensitive with folder names etc.
May return more than one folder, caller should check and handle as appropriate.
#>
$uri = "/users/$userCanvasId/folders?as_user_id=$userCanvasId"
$canvasFolders = Invoke-CanvasApiRequest -uri $uri
if(-NOT [String]::IsNullOrWhiteSpace($folderName)) {
if(-NOT [String]::IsNullOrWhiteSpace($parentFolderPath)) {
# Strip off any trailing slashes if user has included them in path.
while($parentFolderPath[$parentFolderPath.Length - 1] -IN @('/', '\') ) {
$parentFolderPath = $parentFolderPath.Substring(0, $parentFolderPath.Length - 1)
}
$folderPath = "$parentFolderPath/$folderName"
$canvasFolders = $canvasFolders | Where-Object {$_.Full_Name -EQ $folderPath}
} else {
$canvasFolders = $canvasFolders | Where-Object {$_.Name -EQ $folderName}
}
}
Return $canvasFolders
}
Function Send-CanvasFileUpload (
[int] $userCanvasId,
[string] $inputFileName,
[string] $inputFolderPath,
[string] $toFolderPath,
[string] $contentType,
[boolean] $checkQuota = $true
) {
<#
In order to upload a file to Canvas, we first notify the Canvas system of our intention.
We specify the file name and size etc. in a POST request.
We also specify the destination folder ID that we want to upload the file to.
If a file upload is approved, the server responds with a URI that the image may be
set to. This URI is temporary and will time out after 30(?) minutes.
.PARAMETER checkQuota
Check the user's file quota before upload?
.PARAMETER inputFolderPath
Folder on the caller's machine to search for file with $fileName.
.PARAMETER contentType
MIME type of the file.
#>
# Strip off any trailing slashes if user has included them in path.
while($inputFolderPath[$inputFolderPath.Length - 1] -IN @('/', '\') ) {
$inputFolderPath = $inputFolderPath.Substring(0, $inputFolderPath.Length - 1)
}
$inputFilePath = "$inputFolderPath\$inputFileName"
if((Test-Path -Path $inputFilePath) -EQ $false) {
Throw "No file found at: $inputFilePath"
}
$fileSizeBytes = (Get-Item $inputFilePath).Length
IF($checkQuota) {
$uri = "/users/$userCanvasId/files/quota?as_user_id=$userCanvasId"
$quotaResponse = Invoke-CanvasApiRequest -uri $uri
$quotaRemaining = $quotaResponse.quota - $quotaResponse.quota_used
if ($quotaRemaining -LT $fileSizeBytes) {
[string] $errorMessage = "ERROR: User does not have sufficient file quota remaining to enable upload." `
+ ("`n`tQUOTA: {0,15:n0} bytes" -f $quotaResponse.quota) `
+ ("`n`tUSED: {0,15:n0} bytes" -f $quotaResponse.quota_used) `
+ "`nUpload failed."
Throw $errorMessage
}
}
<# If CONTENT_TYPE is omitted it should be guessed when received by the Canvas API
based on file extension. #>
$notifyForm = @{
name = $inputFileName
size = $fileSizeBytes
content_type = $contentType
parent_folder_path = $toFolderPath
}
$uri = "/users/$userCanvasId/files?as_user_id=$userCanvasId"
$notifyResponse = Invoke-CanvasApiRequest `
-method 'POST' `
-URI $uri `
-form $notifyForm
if ($notifyResponse -EQ $null `
-OR [String]::IsNullOrWhiteSpace($notifyResponse.upload_url)) {
Throw "ERROR: Could not get desination URI for file upload. Cannot send file."
}
<#
We need to use CURL here to upload the file, rather than a POST via
Invoke-RestMethod, because I can't figure out how to set the content-type of
the form member named 'File' correctly (and no, the -ContentType parameter for
Invoke-RestMethod doesn't work, as it is ignored when posting a Form using
multipart/form-data).
Might be able to get it to work using this method, haven't tried yet:
https://get-powershellblog.blogspot.com/2017/09/multipartform-data-support-for-invoke.html
#>
$curlCommand = "curl -X POST '$($notifyResponse.upload_url)' " `
+ "-F filename='$inputFileName' " `
+ "-F content-type='$contentType' " `
+ "-F file=@'$inputfilePath' "
Write-Debug "Running CURL command: $curlCommand"
$curlResponse = Invoke-Expression -Command $curlCommand
$uploadResponse = $curlResponse | Convertfrom-Json
# If the file has uploaded successfully, the response object will contain information about the uploaded file.
if ($uploadResponse -EQ $null) {
Throw "ERROR: File upload failed for an unknown reason."
}
return $uploadResponse
}
Function Get-CanvasUserFiles (
[int] $userCanvasId,
[string] $searchTerm,
[boolean] $folderInfo = $true
) {
<#
Be careful of case sensitivity, I have a feeling that Canvas API is
case sensitive with some/all names/paths etc.
This returns all user files. I don't think the API has a way to limit
the response to a file query by folder or path, so I will leave filtering
by folder to the calling process.
.PARAMETER searchTerm
Must be 2 or more characters.
.PARAMETER folderInfo
Add extra information to each file about it's parent folder.
May take additional time so might slow down large queries.
#>
$uri = "/users/$userCanvasId/files"
if(-NOT [String]::IsNullOrWhiteSpace($searchTerm)) {
$uri += "?search_term=$searchTerm"
}
$files = Invoke-CanvasApiRequest -uri $uri
if($folderInfo){
foreach($file in $files) {
$uri = "/users/$userCanvasId/folders/$($file.folder_id)"
$folder = Invoke-CanvasApiRequest -uri $uri
if($folder -NE $null) {
Add-Member `
-InputObject $file `
-NotePropertyName 'folder_name' `
-NotePropertyValue $folder.Name `
-Force
Add-Member `
-InputObject $file `
-NotePropertyName 'folder_path' `
-NotePropertyValue $folder.Full_Name `
-Force
}
}
}
Return $files
}
Function Set-CanvasUserAvatar(
[int] $userCanvasId,
[string] $fileUuid
) {
<#
.PARAMETER fileUuid
The UUID of a file which has been returned via the 'files' endpoint in the
Canvas API.
#>
[boolean] $avatarSetResult = $false
<# Get a list of the avatar images currently available for the user in Canvas.
This list will contain some default images (e.g. when nothing else is available)
as well as any images which (I think) need to be in the 'My Files\profile pictures'
folder to be available. #>
$uri = "/users/$userCanvasId/avatars?as_user_id=$userCanvasId"
$availableAvatars = Invoke-CanvasApiRequest -uri $uri
$wantAvatar = $availableAvatars `
| Where-Object { ($_ | Get-Member -Name 'uuid') -AND $_.uuid -EQ $fileUuid}
if ($wantAvatar -EQ $null) {
Throw "The requested file UUID ($fileUuid) was not found as an available avatar image for user $userCanvasId."
}
Write-Debug "Assigning image file with TOKEN: $($wantAvatar.token) as user avatar..."
<# I can't get this to work with the parameters in a form submission, but it seems
to work fine in the url so whatever. #>
$uri = "/users/$($userCanvasId)?user[avatar][token]=$($wantAvatar.token)"
$setAvatarResponse = Invoke-CanvasApiRequest -method 'PUT' -uri $uri
if ($setAvatarResponse.avatar_url -MATCH ($SCRIPT:validAvatarUrlPattern -f $fileUuid) ) {
Write-Debug "OK, avatar assigned successfully."
$avatarSetResult = $true
} else {
Write-Debug "ERROR: There was a problem assigning the avatar for UUID: $fileUuid."
Write-Debug "Current avatar URL: $($setAvatarResponse.avatar_url)."
}
return $avatarSetResult
}
Function Sync-CanvasAvatar (
[string] $userCanvasId
) {
Write-Host "Getting info for Canvas user: $userCanvasId"
$user = Get-UserInfo -userCanvasId $userCanvasId
Write-Host "CurrentAvatar: $($user.avatar_url)"
<# I have observed some problems when over-writing an image with the same name.
Best method to set profile image is to just delete profile pictures folder then recreate.
We assume that the profile pictures folder does not need to contain anything more than
the current profile image. #>
$profilePicturesFolder = Get-CanvasUserFolders `
-userCanvasId $user.id `
-folderName $SCRIPT:canvasProfileFolderName `
-parentFolderPath $SCRIPT:canvasProfileFolderParentPath
| Sort-Object -Top 1
if ($profilePicturesFolder -NE $null) {
$uri = "/folders/$($profilePicturesFolder.id)?force=true&as_user_id=$($user.id)"
$deleteResponse = Invoke-CanvasApiRequest -method 'DELETE' -uri $uri
Start-Sleep -Seconds 5
}
Write-Host "Uploading profile image file..."
$uploadResponse = Send-CanvasFileUpload `
-userCanvasId $user.id `
-inputFileName $user.imageFileName `
-inputFolderPath $user.imageFolder `
-toFolderPath $SCRIPT:canvasProfileFolderName `
-contentType $SCRIPT:imageFileContentType
if($uploadResponse -EQ $null -OR $uploadResponse.upload_status -NE 'success') {
Throw "Unable to upload file for user."
}
<# Not sure but Canvas appears to have a weird thing where if you upload a file
which is a duplicate of an existing one, the upload response shows 'success' and a
new file UUID, but the old file (and its UUID) are retained. So, we have to
get the avatar file now explicitly rather than using the UUID in the upload_response. #>
$userFiles = Get-CanvasUserFiles -userCanvasId $user.id -searchTerm $user.imageFileName
$avatarImage = $userFiles `
| Where-Object {$_.folder_path -EQ $SCRIPT:canvasProfileFolderPath `
-AND $_.filename -EQ $user.imageFileName} `
| Sort-Object -Unique -Top 1
if($avatarImage -EQ $null) {
Throw "Could not find valid avatar image for user $($user.name) [$($user.sis_user_id)]."
}
Write-Host "Assigning user's avatar..."
$assignmentResult = Set-CanvasUserAvatar `
-userCanvasId $user.id `
-fileUuid $avatarImage.uuid
if( $assignmentResult -EQ $false ) {
Write-Host "ERROR: Avatar image assignment failed."
Return
}
}
###########################################################################
# MAIN
###########################################################################
<# Filter out users who we don't want to include in sync.
Insert your own filtering term here. #>
$currentUsers = Invoke-CanvasApiRequest -Uri '/accounts/1/users' `
| Where-Object { ($_ | Get-Member -Name 'login_id') `
-AND $_.login_id -MATCH '<YOUR_PATTERN_HERE>'}
foreach($user in $currentUsers) {
try {
Sync-CanvasAvatar -userCanvasId $user.id
} catch {
# Catch any errors so we can continue processing users.
$ex = $_
Write-Error $ex
}
}
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.