SPMT_utils.ps1
# This file contains utility functions to implement protocol used by # SharePoint Migration Tool (SPMT) and Migration Manager agent # Calculates SPO SPMT Guid for the given file # Ref: Microsoft.SharePoint.Migration.Common.GuidGenerator # Nov 23rd 2022 Function Get-SPMTFileGuid { [cmdletbinding()] param( [parameter(Mandatory=$true,ValueFromPipeline)] [String]$FilePath ) Process { # Byte order swapping function function Swap-ByteOrder { [cmdletbinding()] param( [parameter(Mandatory=$true,ValueFromPipeline)] [byte[]]$Guid ) Process { $Guid = Swap-Bytes -Guid $Guid -Left 0 -Right 3 $Guid = Swap-Bytes -Guid $Guid -Left 1 -Right 2 $Guid = Swap-Bytes -Guid $Guid -Left 4 -Right 5 $Guid = Swap-Bytes -Guid $Guid -Left 6 -Right 7 return $Guid } } # Byte swapping function function Swap-Bytes { [cmdletbinding()] param( [parameter(Mandatory=$true,ValueFromPipeline)] [byte[]]$Guid, [parameter(Mandatory=$true)] [int]$Left = 0, [parameter(Mandatory=$true)] [int]$Right = 0 ) Process { $b = $Guid[$Left] $Guid[$Left] = $Guid[$Right] $Guid[$Right] = $b return $Guid } } $bytes = [text.encoding]::UTF8.GetBytes($FilePath) $nameSpaceId = [byte[]](Swap-ByteOrder -Guid ([guid]"6ba7b811-9dad-11d1-80b4-00c04fd430c8").ToByteArray()) $alg = 0x05 # SHA1 $sha1 = [System.Security.Cryptography.SHA1]::Create() $sha1.TransformBlock($nameSpaceId, 0, $nameSpaceId.Length, $null, 0) | Out-Null $sha1.TransformFinalBlock($bytes, 0, $bytes.Length) | Out-Null $hash = $sha1.Hash $retVal = New-Object byte[] 16 [Array]::Copy($hash,$retVal,16) $retVal[6] = $retVal[6] -band 15 -bor ($alg -shl 4) $retVal[8] = $retVal[8] -band 63 -bor 128 return [guid][byte[]](Swap-ByteOrder -Guid $retVal) } } # Encrypt the given content for migration # Nov 23rd 2022 function Encrypt-SPMTFile { [cmdletbinding()] Param( [Parameter(Mandatory=$True)] [byte[]]$Key, [Parameter(ParameterSetName='String',Mandatory=$True)] [string]$StringContent, [Parameter(ParameterSetName='Binary',Mandatory=$True)] [byte[]]$BinaryContent, [Parameter(ParameterSetName='File',Mandatory=$True)] [string]$FilePath ) Process { # Create encryptor and use the given key $aes = [System.Security.Cryptography.AesCryptoServiceProvider]::new() $aes.Key = $key $encryptor = $aes.CreateEncryptor() $iv = Convert-ByteArrayToB64 -Bytes $aes.IV if(![string]::IsNullOrEmpty($StringContent)) { Write-Verbose $StringContent $BinaryContent = [text.encoding]::UTF8.GetBytes($StringContent) } # Encrypt content if(![string]::IsNullOrEmpty($FilePath)) { # Open the file $infs = [System.IO.FileStream]::new($filePath,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read) # Create a temporary file $tempFile = (New-TemporaryFile).FullName $outfs = [System.IO.FileStream]::new($tempFile,[System.IO.FileMode]::OpenOrCreate,[System.IO.FileAccess]::Write) # Read and encrypt the file in 1kb chunks $cs = [System.Security.Cryptography.CryptoStream]::new($outfs,$encryptor,[System.Security.Cryptography.CryptoStreamMode]::Write) $buffer = New-Object byte[] 1024 while($infs.Position -lt $infs.Length) { $read = $infs.Read($buffer,0,1024) $cs.Write($buffer,0,$read) } $cs.FlushFinalBlock() # Clean up $infs.Close() $infs.Dispose() $outfs.Close() $outfs.Dispose() $cs.Close() $cs.Dispose() $aes.Dispose() # Calculate MD5 $md5 = Convert-ByteArrayToB64 (Convert-HexToByteArray (Get-FileHash -Path $tempFile -Algorithm MD5).Hash) } else { # Encrypt in memory $ms = [System.IO.MemoryStream]::new() $cs = [System.Security.Cryptography.CryptoStream]::new($ms,$encryptor,[System.Security.Cryptography.CryptoStreamMode]::Write) $cs.Write($BinaryContent,0,$BinaryContent.Count) $cs.FlushFinalBlock() $encData = $ms.ToArray() # Clean up $cs.Close() $cs.Dispose() $ms.Close() $ms.Dispose() $aes.Dispose() # Calculate MD5 $md5hash = [System.Security.Cryptography.MD5]::Create() $md5 = Convert-ByteArrayToB64 -Bytes $md5hash.ComputeHash($encData) $md5hash.Dispose() } # Return return [PSCustomObject]@{ "Data" = $encData "IV" = $iv "MD5" = $MD5 "DataFile" = $tempFile } } } # Generate metadatafiles for migration # Nov 24th 2022 function Generate-SPMTMetadata { [cmdletbinding()] Param( [Parameter(Mandatory=$True)] [PSObject]$ContainerInfo, [Parameter(Mandatory=$True)] [PSObject]$FolderInfo, [Parameter(Mandatory=$True)] [guid]$WebId, [Parameter(Mandatory=$True)] [PSObject]$UserInformation, [Parameter(Mandatory=$True)] [Hashtable]$Files, [Parameter(Mandatory=$True)] [string]$Site ) Process { $metadataFiles = @{} $key = Convert-B64ToByteArray -B64 $ContainerInfo.EncryptionKey # Create ExportSettings.xml Write-Verbose "Generating ExportSettings.xml" $content = @" <?xml version="1.0" encoding="utf-8"?> <ExportSettings xmlns="urn:deployment-exportsettings-schema" SiteUrl="http://fileshare/sites/user" FileLocation="C:\" IncludeSecurity="All" SourceType="FileShare"> <ExportObjects/> </ExportSettings> "@ $metadataFiles["ExportSettings.xml"] = Encrypt-SPMTFile -Key $key -StringContent $content # Create SystemData.xml Write-Verbose "Generating SystemData.xml" $content = @" <?xml version="1.0" encoding="utf-8"?> <SystemData xmlns="urn:deployment-systemdata-schema"> <SchemaVersion Version="15.0.0.0" Build="16.0.3111.1200" DatabaseVersion="11552" SiteVersion="15" ObjectsProcessed="7" /> <ManifestFiles> <ManifestFile Name="Manifest.xml" /> </ManifestFiles> <SystemObjects/> </SystemData> "@ $metadataFiles["SystemData.xml"] = Encrypt-SPMTFile -Key $key -StringContent $content # Create UserGroup.xml Write-Verbose "Generating UserGroup.xml" $content = @" <?xml version="1.0" encoding="utf-8"?> <UserGroupMap xmlns="urn:deployment-usergroupmap-schema"> <Users> <User Id="1" Name="$($UserInformation.Title)" Login="$($UserInformation.LoginName)" IsSiteAdmin="false" SystemId="$(Convert-ByteArrayToB64 ([text.encoding]::UTF8.GetBytes($UserInformation.NameId)))" IsDeleted="false" IsDomainGroup="false" /> </Users> <Groups /> </UserGroupMap> "@ $metadataFiles["UserGroup.xml"] = Encrypt-SPMTFile -Key $key -StringContent $content # Create Requirements.xml Write-Verbose "Generating Requirements.xml" $content = @" <?xml version="1.0" encoding="utf-8"?> <Requirements xmlns="urn:deployment-requirements-schema" /> "@ $metadataFiles["Requirements.xml"] = Encrypt-SPMTFile -Key $key -StringContent $content # Create Manifest.xml Write-Verbose "Generating Manifest.xml" $content = @" <?xml version="1.0" encoding="utf-8"?> <SPObjects xmlns="urn:deployment-manifest-schema"> "@ $id = 1 foreach($fileName in $Files.Keys) { $fileInfo = $Files[$fileName] $content += @" <SPObject Id="$($fileInfo.Guid)" ObjectType="SPFile"> <File Url="$($FolderInfo.Name)/$fileName" Id="$($fileInfo.Guid)" ParentWebId="$WebId" Name="$fileName" ParentId="$($FolderInfo.Id)" TimeCreated="$($fileInfo.TimeCreated)" TimeLastModified="$($fileInfo.TimeLastModified)" Version="1.0" FileValue="$($fileInfo.Guid).dat" Author="1" ModifiedBy="1" MD5Hash="$($fileInfo.MD5)" InitializationVector="$($fileInfo.IV)" /> </SPObject> "@ } $content += @" </SPObjects> "@ $metadataFiles["Manifest.xml"] = Encrypt-SPMTFile -Key $key -StringContent $content return $metadataFiles } } # Generate metadatafiles for migration # Nov 24th 2022 function Generate-SPMTFiledata { [cmdletbinding()] Param( [Parameter(Mandatory=$False)] [string]$Cookie, [Parameter(Mandatory=$False)] [string]$AccessToken, [Parameter(Mandatory=$True)] [string]$Site, [Parameter(Mandatory=$True)] [PSObject]$FolderInfo, [Parameter(Mandatory=$True)] [PSObject]$ContainerInfo, [Parameter(Mandatory=$True)] [string[]]$Files, [Parameter(Mandatory=$False)] [string]$LocalFile, [Parameter(Mandatory=$False)] [PSObject]$TimeCreated, [Parameter(Mandatory=$False)] [PSObject]$TimeLastModified, [Parameter(Mandatory=$False)] [PSObject]$Id, [Parameter(Mandatory=$False)] [String]$RelativePath ) Process { $fileData = @{} $key = Convert-B64ToByteArray -B64 $ContainerInfo.EncryptionKey foreach($file in $Files) { if((Test-Path $file) -or (Test-Path $LocalFile)) { if($LocalFile) { # Get local file if provided (may have different name than the target one when replacing) $fileItem = Get-Item $LocalFile $fileName = $file } else { # Get file item $fileItem = Get-Item $file $fileName = $fileItem.Name } Write-Verbose "Processing file $($fileItem.FullName)" # Encrypt $fileInfo = Encrypt-SPMTFile -Key $key -FilePath $fileItem.FullName # Add created and modified time if($TimeCreated -eq $null) { $TimeCreated = $fileItem.CreationTimeUtc } if($TimeLastModified -eq $null) { $TimeLastModified = $fileItem.LastWriteTimeUtc } # We are replacing an existing file so use that guid if($Id) { $guid = $Id } else { # Form the filepath for calculating guid $filePath = $Site + $FolderInfo.Name + $FolderInfo.ListName + $fileName $guid = Get-SPMTFileGuid -FilePath $FilePath } $fileInfo | Add-Member -NotePropertyName "TimeCreated" -NotePropertyValue $TimeCreated.ToString("o").Split(".")[0] $fileInfo | Add-Member -NotePropertyName "TimeLastModified" -NotePropertyValue $TimeLastModified.ToString("o").Split(".")[0] $fileInfo | Add-Member -NotePropertyName "Guid" -NotePropertyValue $guid $fileData[$fileName] = $fileInfo } else { Write-Warning "File does not exist, skipping: $file" } } return $fileData } } # Send files # Nov 24th 2022 function Send-SPMTFiles { [cmdletbinding()] Param( [Parameter(Mandatory=$True)] [Hashtable]$Files, [Parameter(Mandatory=$True)] [Hashtable]$Metadata, [Parameter(Mandatory=$True)] [PSObject]$ContainerInfo ) Process { if($Files.Count -lt 1) { throw "No files to be sent" } Write-Verbose "Sending $($Files.Count) file(s) and $($Metadata.Count) metadata file(s) to SPO" # Send metadata foreach($fileName in $Metadata.Keys) { $metadataInfo = $Metadata[$fileName] Write-Verbose "Sending metadata: $($metadataInfo.Guid) $fileName " # Create the url $url = $ContainerInfo.MetadataContainerUri.Replace("?","/$fileName`?") $url += "&api-version=2018-03-28" # Create headers $headers=@{ "x-ms-client-request-id" = (New-Guid).ToString() "Content-MD5" = $metadataInfo.MD5 "x-ms-blob-type" = "BlockBlob" "x-ms-version" = "2018-03-28" } # Send the file $response = Invoke-RestMethod -UseBasicParsing -Method Put -Uri "$url&timeout=300" -Headers $headers -Body $metadataInfo.Data # Create headers for IV $headers=@{ "x-ms-client-request-id" = (New-Guid).ToString() "x-ms-meta-IV" = $metadataInfo.IV "x-ms-version" = "2018-03-28" } # Send the IV $response = Invoke-RestMethod -UseBasicParsing -Method Put -Uri "$url&comp=metadata" -Headers $headers # Create headers for snapshot $headers=@{ "x-ms-client-request-id" = (New-Guid).ToString() "x-ms-version" = "2018-03-28" } # Create a snapshot $response = Invoke-RestMethod -UseBasicParsing -Method Put -Uri "$url&comp=snapshot" -Headers $headers } # Send files foreach($fileName in $Files.Keys) { Write-Verbose "Sending file: $fileName" $fileInfo = $Files[$fileName] # Create the url $url = $ContainerInfo.DataContainerUri.Replace("?","/$($fileInfo.Guid).dat?") $url += "&api-version=2018-03-28" # Create headers $headers=@{ "x-ms-client-request-id" = (New-Guid).ToString() "Content-MD5" = $fileInfo.MD5 "x-ms-blob-type" = "BlockBlob" "x-ms-version" = "2018-03-28" } # Send the file and delete temporary file $response = Invoke-RestMethod -UseBasicParsing -Method Put -Uri "$url&timeout=300" -Headers $headers -InFile $fileInfo.DataFile Remove-Item -Path $fileInfo.DataFile -Force -ErrorAction SilentlyContinue # Create headers for IV $headers=@{ "x-ms-client-request-id" = (New-Guid).ToString() "x-ms-meta-IV" = $fileInfo.IV "x-ms-version" = "2018-03-28" } # Send the IV $response = Invoke-RestMethod -UseBasicParsing -Method Put -Uri "$url&comp=metadata" -Headers $headers # Create headers for snapshot $headers=@{ "x-ms-client-request-id" = (New-Guid).ToString() "x-ms-version" = "2018-03-28" } # Create a snapshot $response = Invoke-RestMethod -UseBasicParsing -Method Put -Uri "$url&comp=snapshot" -Headers $headers } } } # Poll messages # Nov 24th 2022 function Start-SPMTPoll { [cmdletbinding()] Param( [Parameter(Mandatory=$True)] [PSObject]$ContainerInfo, [Parameter(Mandatory=$True)] [guid]$JobId ) Process { # Decode the key $key = Convert-B64ToByteArray -B64 $ContainerInfo.EncryptionKey # Start polling for messages from migration queue $jobQueueUri = $ContainerInfo.JobQueueUri $continue = $true Write-Verbose "Polling messages.." while($continue) { # Create the url $url = $jobQueueUri.Replace("?","/messages?") $createUrl = $url + "&api-version=2018-03-28&numofmessages=30&timeout=5" # Create headers $headers=@{ "x-ms-client-request-id" = (New-Guid).ToString() "x-ms-version" = "2018-03-28" } # Get message $response = Invoke-WebRequest -UseBasicParsing -Method Get -Uri $createUrl -Headers $headers $responseBytes = New-Object byte[] $response.RawContentLength $response.RawContentStream.Read($responseBytes,0,$response.RawContentLength) | Out-Null # Strip the BOM [xml]$queueResponse = [text.encoding]::UTF8.getString([byte[]](Remove-BOM -ByteArray $responseBytes)) # Parse messages foreach($queueMessage in $queueResponse.QueueMessagesList.ChildNodes) { $messageText = ConvertFrom-Json (Convert-B64ToText -B64 $queueMessage.MessageText) Write-Verbose "Received message $($queueMessage.MessageId)" # Check the JobId if([guid]$messageText.JobId -ne $JobId) { Write-Warning "Message $($queueMessage.MessageId) is for wrong job ($($messageText.JobId)). Was expecting $JobId" } # Decrypt the message if($messageText.Label -eq "Encrypted") { $iv = Convert-B64ToByteArray -B64 $messageText.IV $encData = Convert-B64ToByteArray -B64 $messageText.Content $aes = [System.Security.Cryptography.AesCryptoServiceProvider]::new() $aes.Key = $key $aes.IV = $iv $ms = [System.IO.MemoryStream]::new() $decryptor = $aes.CreateDecryptor() $cs = [System.Security.Cryptography.CryptoStream]::new($ms,$decryptor,[System.Security.Cryptography.CryptoStreamMode]::Write) $cs.Write($encData,0,$encData.Count) $cs.FlushFinalBlock() $decData = $ms.ToArray() $ms.Close() $cs.Close() $decryptor.Dispose() $messageText.Content = [text.encoding]::UTF8.GetString($decData) } $content = $messageText.Content | ConvertFrom-Json Write-Verbose $content # Delete the message from the server $deleteUrl = $url.Replace("?","/$($queueMessage.MessageId)?") $deleteUrl += "&api-version=2018-03-28&popreceipt=$([System.Web.HttpUtility]::UrlEncode($queueMessage.PopReceipt))" $response = Invoke-WebRequest -UseBasicParsing -Method Delete -Uri $deleteUrl -Headers $headers Write-Host "$($content.Time) $($content.Event)" switch($content.Event) { "JobEnd" { $continue = $false Write-Host "$($content.FilesCreated) files ($('{0:N0}' -f $content.BytesProcessed) bytes) sent." break } } if($content.Message) { Write-Host $content.Message -ForegroundColor DarkYellow } } if($continue) { Start-Sleep -Seconds 5 } } } } # Create migration job # Nov 24th 2022 function New-SPMTMigrationJob { [cmdletbinding()] Param( [Parameter(Mandatory=$False)] [string]$Cookie, [Parameter(Mandatory=$False)] [string]$AccessToken, [Parameter(Mandatory=$True)] [string]$Site, [Parameter(Mandatory=$True)] [PSObject]$ContainerInfo ) Process { # Get digest $digest = Get-SPODigest -Cookie $cookie -AccessToken $AccessToken -Site $site # body for site id $Body=@" <Request AddExpandoFieldTypeSuffix="true" SchemaVersion="15.0.0.0" LibraryVersion="16.0.0.0" ApplicationName=".NET Library" xmlns="http://schemas.microsoft.com/sharepoint/clientquery/2009"> <Actions> <ObjectPath Id="2" ObjectPathId="1"/> <ObjectPath Id="4" ObjectPathId="3"/> <ObjectPath Id="6" ObjectPathId="5"/> <Query Id="7" ObjectPathId="3"> <Query SelectAllProperties="false"> <Properties> <Property Name="RootWeb"> <Query SelectAllProperties="false"> <Properties> <Property Name="Id" ScalarProperty="true"/> </Properties> </Query> </Property> </Properties> </Query> </Query> <Query Id="9" ObjectPathId="5"> <Query SelectAllProperties="false"> <Properties> <Property Name="Id" ScalarProperty="true"/> </Properties> </Query> </Query> </Actions> <ObjectPaths> <StaticProperty Id="1" TypeId="{3747adcd-a3c3-41b9-bfab-4a64dd2f1e0a}" Name="Current"/> <Property Id="3" ParentId="1" Name="Site"/> <Property Id="5" ParentId="1" Name="Web"/> </ObjectPaths> </Request> "@ # Invoke ProcessQuery to get site id $response = Invoke-ProcessQuery -Cookie $Cookie -AccessToken $AccessToken -Site $site -Body $Body -Digest $digest $content = ($response.content | ConvertFrom-Json) $SPWebIdentity = $content[$content.Count -1]._ObjectIdentity_ $SPSiteIdentity = $SPWebIdentity.Substring(0,$SPWebIdentity.IndexOf(":web:")) # Body for starting the job (must be linearised...) $Body=@" <Request AddExpandoFieldTypeSuffix="true" SchemaVersion="15.0.0.0" LibraryVersion="16.0.0.0" ApplicationName=".NET Library" xmlns="http://schemas.microsoft.com/sharepoint/clientquery/2009"><Actions><Method Name="CreateMigrationJobEncrypted" Id="14" ObjectPathId="3"><Parameters><Parameter Type="Guid">{5ac5b4f2-8830-4b68-8811-276e29e0595d}</Parameter><Parameter Type="String">$($ContainerInfo.DataContainerUri.Replace("&","&").Replace("0:0","0%3A0"))</Parameter><Parameter Type="String">$($ContainerInfo.MetadataContainerUri.Replace("&","&").Replace("0:0","0%3A0"))</Parameter><Parameter Type="String">$($ContainerInfo.JobQueueUri.Replace("&","&").Replace("0:0","0%3A0"))</Parameter><Parameter TypeId="{85614ad4-7a40-49e0-b272-6d1807dbfcc6}"><Property Name="AES256CBCKey" Type="Base64Binary">$($ContainerInfo.EncryptionKey)</Property></Parameter></Parameters></Method></Actions><ObjectPaths><Identity Id="3" Name="$SPSiteIdentity"/></ObjectPaths></Request> "@ # Invoke ProcessQuery $response = Invoke-ProcessQuery -Cookie $Cookie -AccessToken $AccessToken -Site $site -Body $Body -Digest $digest $content = ($response.content | ConvertFrom-Json) [guid]$guid = $content[$content.Count -1].Split("(")[1].Split(")")[0] return $guid } } # Send given file(s) to given SPO site # Nov 23rd 2022 function Send-SPOFiles { [cmdletbinding()] Param( [Parameter(Mandatory=$True)] [string]$Site, [Parameter(Mandatory=$True)] [string]$FolderName, [Parameter(Mandatory=$True)] [string[]]$Files, [Parameter(Mandatory=$False)] [string]$LocalFile, [Parameter(Mandatory=$True)] [string]$UserName, [Parameter(Mandatory=$False)] [DateTime]$TimeCreated, [Parameter(Mandatory=$False)] [DateTime]$TimeLastModified, [Parameter(Mandatory=$False)] [Guid]$Id ) Process { # Get user information Write-Verbose "Getting user information" try { $userInformation = Get-SPOMigrationUser -Site $Site -UserName $UserName } catch { Write-Error $_.Exception.Message return } # Get the container information Write-Verbose "Getting migration container information" $containerInfo = Get-SPOMigrationContainersInfo -Site $Site # Get folder information Write-Verbose "Getting information for folder '$FolderName'" $folderInformation = Get-SPOSiteFolder -Site $Site -RelativePath $FolderName # Get WebId $webId = Get-SPOWebId -Site $Site # Process the data files (encrypt & get information) $fileData = Generate-SPMTFileData -ContainerInfo $containerInfo -FolderInfo $folderInformation -Files $Files -TimeCreated $TimeCreated -TimeLastModified $TimeLastModified -Id $Id -Site $Site -LocalFile $LocalFile Write-Host "Sending $($fileData.Count) file(s) as `"$($userInformation.LoginName)`" to `"$($Site)/$($folderInformation.Name)`"" # Generate metadata files $metadata = Generate-SPMTMetadata -ContainerInfo $containerInfo -FolderInfo $folderInformation -UserInformation $userInformation -Files $fileData -WebId $webId -Site $Site # Send the files Send-SPMTFiles -Files $fileData -Metadata $metadata -ContainerInfo $containerInfo # Create a new migration job $jobId = New-SPMTMigrationJob -Cookie $Cookie -AccessToken $AccessToken -Site $site -ContainerInfo $containerInfo # Start polling messages Start-SPMTPoll -ContainerInfo $containerInfo -JobId $jobId } } |