How to Bulk Load and Update Profile Avatar Pictures with Powershell - V2

BenjaminSelby
Community Participant
0
1140

I created a blog post in 2020 explaining how to bulk load and sync Canvas Avatar images using Powershell.

https://community.canvaslms.com/t5/Canvas-Developers-Group/How-to-bulk-load-and-update-avatar-profil...

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
    }
}