diff --git a/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccount.Page.al b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccount.Page.al index 6d7ab60256..a2c76416d8 100644 --- a/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccount.Page.al +++ b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccount.Page.al @@ -97,6 +97,15 @@ page 4580 "Ext. SharePoint Account" } } field(Disabled; Rec.Disabled) { } + field("Use Graph API"; Rec."Use Graph API") + { + trigger OnValidate() + var + CheckBasePathMsg: Label 'The API type has been changed. Please verify that the Base Relative Folder Path is still correct for the selected API type.'; + begin + Message(CheckBasePathMsg); + end; + } } } diff --git a/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccount.Table.al b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccount.Table.al index ebc2ac2128..b43c69af66 100644 --- a/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccount.Table.al +++ b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccount.Table.al @@ -34,7 +34,7 @@ table 4580 "Ext. SharePoint Account" field(5; "Base Relative Folder Path"; Text[2048]) { Caption = 'Base Relative Folder Path'; - ToolTip = 'Specifies the folder path relative to the site collection. Start with the document library or folder name (e.g., Shared Documents/Reports). This path can be copied from the URL of the folder in SharePoint after the site collection (e.g., /Shared Documents/Reports from https://mysharepoint.sharepoint.com/sites/ProjectX/Shared%20Documents/Reports).'; + ToolTip = 'Specifies the base folder path. For SharePoint REST API: Use the full server-relative path including the site (e.g., /sites/ProjectX/Shared Documents/Reports). For Microsoft Graph API: Use only the path relative to the document library (e.g., Reports). When using Graph API, the path should not include the site or document library root, only the folders within the library.'; } field(6; "Tenant Id"; Guid) { @@ -50,6 +50,7 @@ table 4580 "Ext. SharePoint Account" } field(8; "Client Secret Key"; Guid) { + Caption = 'Client Secret Key'; Access = Internal; DataClassification = SystemMetadata; } @@ -66,14 +67,23 @@ table 4580 "Ext. SharePoint Account" } field(11; "Certificate Key"; Guid) { + Caption = 'Certificate Key'; Access = Internal; + AllowInCustomizations = Never; DataClassification = SystemMetadata; } field(12; "Certificate Password Key"; Guid) { + Caption = 'Certificate Password Key'; Access = Internal; + AllowInCustomizations = Never; DataClassification = SystemMetadata; } + field(13; "Use Graph API"; Boolean) + { + Caption = 'Use Microsoft Graph API'; + ToolTip = 'Specifies whether to use Microsoft Graph API or SharePoint REST API. Microsoft Graph API supports downloading files larger than 150 MB through chunked transfers. Note: Requires Microsoft Graph permissions (Sites.ReadWrite.All) configured in your app registration instead of SharePoint permissions.'; + } } keys diff --git a/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccountWizard.Page.al b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccountWizard.Page.al index 10fef6fb73..a802e8deb4 100644 --- a/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccountWizard.Page.al +++ b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointAccountWizard.Page.al @@ -43,7 +43,6 @@ page 4581 "Ext. SharePoint Account Wizard" Caption = 'Account Name'; NotBlank = true; ShowMandatory = true; - ToolTip = 'Specifies a descriptive name for this SharePoint storage account connection.'; trigger OnValidate() begin @@ -54,7 +53,6 @@ page 4581 "Ext. SharePoint Account Wizard" field("Tenant Id"; Rec."Tenant Id") { ShowMandatory = true; - ToolTip = 'Specifies the Microsoft Entra ID Tenant ID (Directory ID) where your SharePoint site and app registration are located.'; trigger OnValidate() begin @@ -65,7 +63,6 @@ page 4581 "Ext. SharePoint Account Wizard" field("Client Id"; Rec."Client Id") { ShowMandatory = true; - ToolTip = 'Specifies the Client ID (Application ID) of the App Registration in Microsoft Entra ID.'; trigger OnValidate() begin @@ -75,7 +72,6 @@ page 4581 "Ext. SharePoint Account Wizard" field("Authentication Type"; Rec."Authentication Type") { - ToolTip = 'Specifies the authentication flow used for this SharePoint account. Client Secret uses User grant flow, which means that the user must sign in when using this account. Certificate uses Client credentials flow, which means that the user does not need to sign in when using this account.'; trigger OnValidate() begin UpdateAuthTypeVisibility(); @@ -134,6 +130,9 @@ page 4581 "Ext. SharePoint Account Wizard" IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); end; } + field("Use Graph API"; Rec."Use Graph API") + { + } field("Base Relative Folder Path"; Rec."Base Relative Folder Path") { diff --git a/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointConnectorImpl.Codeunit.al b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointConnectorImpl.Codeunit.al index 6411930b0b..2cd6af703f 100644 --- a/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointConnectorImpl.Codeunit.al +++ b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointConnectorImpl.Codeunit.al @@ -5,10 +5,8 @@ namespace System.ExternalFileStorage; -using System.DataAdministration; -using System.Integration.Sharepoint; using System.Text; -using System.Utilities; +using System.DataAdministration; codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage Connector" { @@ -18,9 +16,13 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage Permissions = tabledata "Ext. SharePoint Account" = rimd; var + RestHelper: Codeunit "Ext. SharePoint REST Helper"; + GraphHelper: Codeunit "Ext. SharePoint Graph Helper"; ConnectorDescriptionTxt: Label 'Use SharePoint to store and retrieve files.', MaxLength = 250; NotRegisteredAccountErr: Label 'We could not find the account. Typically, this is because the account has been deleted.'; + #region File Operations + /// /// Gets a List of Files stored on the provided account. /// @@ -30,28 +32,13 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage /// A list with all files stored in the path. procedure ListFiles(AccountId: Guid; Path: Text; FilePaginationData: Codeunit "File Pagination Data"; var TempFileAccountContent: Record "File Account Content" temporary) var - SharePointFile: Record "SharePoint File"; - SharePointClient: Codeunit "SharePoint Client"; - OrginalPath: Text; + SharePointAccount: Record "Ext. SharePoint Account"; begin - OrginalPath := Path; - InitPath(AccountId, Path); - InitSharePointClient(AccountId, SharePointClient); - if not SharePointClient.GetFolderFilesByServerRelativeUrl(Path, SharePointFile) then - ShowError(SharePointClient); - - FilePaginationData.SetEndOfListing(true); - - if not SharePointFile.FindSet() then - exit; - - repeat - TempFileAccountContent.Init(); - TempFileAccountContent.Name := SharePointFile.Name; - TempFileAccountContent.Type := TempFileAccountContent.Type::"File"; - TempFileAccountContent."Parent Directory" := CopyStr(OrginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory")); - TempFileAccountContent.Insert(); - until SharePointFile.Next() = 0; + SharePointAccount.Get(AccountId); + if SharePointAccount."Use Graph API" then + GraphHelper.ListFiles(SharePointAccount, Path, FilePaginationData, TempFileAccountContent) + else + RestHelper.ListFiles(SharePointAccount, Path, FilePaginationData, TempFileAccountContent); end; /// @@ -62,19 +49,13 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage /// The Stream were the file is read to. procedure GetFile(AccountId: Guid; Path: Text; Stream: InStream) var - SharePointClient: Codeunit "SharePoint Client"; - Content: HttpContent; - TempBlobStream: InStream; + SharePointAccount: Record "Ext. SharePoint Account"; begin - InitPath(AccountId, Path); - InitSharePointClient(AccountId, SharePointClient); - - if not SharePointClient.DownloadFileContentByServerRelativeUrl(Path, TempBlobStream) then - ShowError(SharePointClient); - - // Platform fix: For some reason the Stream from DownloadFileContentByServerRelativeUrl dies after leaving the interface - Content.WriteFrom(TempBlobStream); - Content.ReadAs(Stream); + SharePointAccount.Get(AccountId); + if SharePointAccount."Use Graph API" then + GraphHelper.GetFile(SharePointAccount, Path, Stream) + else + RestHelper.GetFile(SharePointAccount, Path, Stream); end; /// @@ -85,17 +66,13 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage /// The Stream were the file is read from. procedure CreateFile(AccountId: Guid; Path: Text; Stream: InStream) var - SharePointFile: Record "SharePoint File"; - SharePointClient: Codeunit "SharePoint Client"; - ParentPath, FileName : Text; + SharePointAccount: Record "Ext. SharePoint Account"; begin - InitPath(AccountId, Path); - InitSharePointClient(AccountId, SharePointClient); - SplitPath(Path, ParentPath, FileName); - if SharePointClient.AddFileToFolder(ParentPath, FileName, Stream, SharePointFile, false) then - exit; - - ShowError(SharePointClient); + SharePointAccount.Get(AccountId); + if SharePointAccount."Use Graph API" then + GraphHelper.CreateFile(SharePointAccount, Path, Stream) + else + RestHelper.CreateFile(SharePointAccount, Path, Stream); end; /// @@ -106,13 +83,13 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage /// The target file path. procedure CopyFile(AccountId: Guid; SourcePath: Text; TargetPath: Text) var - TempBlob: Codeunit "Temp Blob"; - Stream: InStream; + SharePointAccount: Record "Ext. SharePoint Account"; begin - TempBlob.CreateInStream(Stream); - - GetFile(AccountId, SourcePath, Stream); - CreateFile(AccountId, TargetPath, Stream); + SharePointAccount.Get(AccountId); + if SharePointAccount."Use Graph API" then + GraphHelper.CopyFile(SharePointAccount, SourcePath, TargetPath) + else + RestHelper.CopyFile(SharePointAccount, SourcePath, TargetPath); end; /// @@ -123,11 +100,13 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage /// The target file path. procedure MoveFile(AccountId: Guid; SourcePath: Text; TargetPath: Text) var - Stream: InStream; + SharePointAccount: Record "Ext. SharePoint Account"; begin - GetFile(AccountId, SourcePath, Stream); - CreateFile(AccountId, TargetPath, Stream); - DeleteFile(AccountId, SourcePath); + SharePointAccount.Get(AccountId); + if SharePointAccount."Use Graph API" then + GraphHelper.MoveFile(SharePointAccount, SourcePath, TargetPath) + else + RestHelper.MoveFile(SharePointAccount, SourcePath, TargetPath); end; /// @@ -138,16 +117,13 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage /// Returns true if the file exists procedure FileExists(AccountId: Guid; Path: Text): Boolean var - SharePointFile: Record "SharePoint File"; - SharePointClient: Codeunit "SharePoint Client"; + SharePointAccount: Record "Ext. SharePoint Account"; begin - InitPath(AccountId, Path); - InitSharePointClient(AccountId, SharePointClient); - if not SharePointClient.GetFolderFilesByServerRelativeUrl(GetParentPath(Path), SharePointFile) then - ShowError(SharePointClient); - - SharePointFile.SetRange(Name, GetFileName(Path)); - exit(not SharePointFile.IsEmpty()); + SharePointAccount.Get(AccountId); + if SharePointAccount."Use Graph API" then + exit(GraphHelper.FileExists(SharePointAccount, Path)) + else + exit(RestHelper.FileExists(SharePointAccount, Path)); end; /// @@ -157,14 +133,13 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage /// The file path inside the file account. procedure DeleteFile(AccountId: Guid; Path: Text) var - SharePointClient: Codeunit "SharePoint Client"; + SharePointAccount: Record "Ext. SharePoint Account"; begin - InitPath(AccountId, Path); - InitSharePointClient(AccountId, SharePointClient); - if SharePointClient.DeleteFileByServerRelativeUrl(Path) then - exit; - - ShowError(SharePointClient); + SharePointAccount.Get(AccountId); + if SharePointAccount."Use Graph API" then + GraphHelper.DeleteFile(SharePointAccount, Path) + else + RestHelper.DeleteFile(SharePointAccount, Path); end; /// @@ -176,28 +151,13 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage /// A list with all directories stored in the path. procedure ListDirectories(AccountId: Guid; Path: Text; FilePaginationData: Codeunit "File Pagination Data"; var TempFileAccountContent: Record "File Account Content" temporary) var - SharePointFolder: Record "SharePoint Folder"; - SharePointClient: Codeunit "SharePoint Client"; - OrginalPath: Text; + SharePointAccount: Record "Ext. SharePoint Account"; begin - OrginalPath := Path; - InitPath(AccountId, Path); - InitSharePointClient(AccountId, SharePointClient); - if not SharePointClient.GetSubFoldersByServerRelativeUrl(Path, SharePointFolder) then - ShowError(SharePointClient); - - FilePaginationData.SetEndOfListing(true); - - if not SharePointFolder.FindSet() then - exit; - - repeat - TempFileAccountContent.Init(); - TempFileAccountContent.Name := SharePointFolder.Name; - TempFileAccountContent.Type := TempFileAccountContent.Type::Directory; - TempFileAccountContent."Parent Directory" := CopyStr(OrginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory")); - TempFileAccountContent.Insert(); - until SharePointFolder.Next() = 0; + SharePointAccount.Get(AccountId); + if SharePointAccount."Use Graph API" then + GraphHelper.ListDirectories(SharePointAccount, Path, FilePaginationData, TempFileAccountContent) + else + RestHelper.ListDirectories(SharePointAccount, Path, FilePaginationData, TempFileAccountContent); end; /// @@ -207,15 +167,13 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage /// The directory path inside the file account. procedure CreateDirectory(AccountId: Guid; Path: Text) var - SharePointFolder: Record "SharePoint Folder"; - SharePointClient: Codeunit "SharePoint Client"; + SharePointAccount: Record "Ext. SharePoint Account"; begin - InitPath(AccountId, Path); - InitSharePointClient(AccountId, SharePointClient); - if SharePointClient.CreateFolder(Path, SharePointFolder) then - exit; - - ShowError(SharePointClient); + SharePointAccount.Get(AccountId); + if SharePointAccount."Use Graph API" then + GraphHelper.CreateDirectory(SharePointAccount, Path) + else + RestHelper.CreateDirectory(SharePointAccount, Path); end; /// @@ -226,15 +184,13 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage /// Returns true if the directory exists procedure DirectoryExists(AccountId: Guid; Path: Text) Result: Boolean var - SharePointClient: Codeunit "SharePoint Client"; + SharePointAccount: Record "Ext. SharePoint Account"; begin - InitPath(AccountId, Path); - InitSharePointClient(AccountId, SharePointClient); - - Result := SharePointClient.FolderExistsByServerRelativeUrl(Path); - - if not SharePointClient.GetDiagnostics().IsSuccessStatusCode() then - ShowError(SharePointClient); + SharePointAccount.Get(AccountId); + if SharePointAccount."Use Graph API" then + exit(GraphHelper.DirectoryExists(SharePointAccount, Path)) + else + exit(RestHelper.DirectoryExists(SharePointAccount, Path)); end; /// @@ -244,16 +200,17 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage /// The directory path inside the file account. procedure DeleteDirectory(AccountId: Guid; Path: Text) var - SharePointClient: Codeunit "SharePoint Client"; + SharePointAccount: Record "Ext. SharePoint Account"; begin - InitPath(AccountId, Path); - InitSharePointClient(AccountId, SharePointClient); - if SharePointClient.DeleteFolderByServerRelativeUrl(Path) then - exit; - - ShowError(SharePointClient); + SharePointAccount.Get(AccountId); + if SharePointAccount."Use Graph API" then + GraphHelper.DeleteDirectory(SharePointAccount, Path) + else + RestHelper.DeleteDirectory(SharePointAccount, Path); end; + #endregion + /// /// Gets the registered accounts for the SharePoint connector. /// @@ -383,91 +340,6 @@ codeunit 4580 "Ext. SharePoint Connector Impl" implements "External File Storage TempFileAccount.Connector := Enum::"Ext. File Storage Connector"::"SharePoint"; end; - local procedure InitSharePointClient(var AccountId: Guid; var SharePointClient: Codeunit "SharePoint Client") - var - SharePointAccount: Record "Ext. SharePoint Account"; - SharePointAuth: Codeunit "SharePoint Auth."; - SharePointAuthorization: Interface "SharePoint Authorization"; - Scopes: List of [Text]; - AccountDisabledErr: Label 'The account "%1" is disabled.', Comment = '%1 - Account Name'; - begin - SharePointAccount.Get(AccountId); - if SharePointAccount.Disabled then - Error(AccountDisabledErr, SharePointAccount.Name); - - Scopes.Add('00000003-0000-0ff1-ce00-000000000000/.default'); - - case SharePointAccount."Authentication Type" of - Enum::"Ext. SharePoint Auth Type"::"Client Secret": - SharePointAuthorization := SharePointAuth.CreateAuthorizationCode( - Format(SharePointAccount."Tenant Id", 0, 4), - Format(SharePointAccount."Client Id", 0, 4), - SharePointAccount.GetClientSecret(SharePointAccount."Client Secret Key"), - Scopes); - Enum::"Ext. SharePoint Auth Type"::Certificate: - SharePointAuthorization := SharePointAuth.CreateClientCredentials( - Format(SharePointAccount."Tenant Id", 0, 4), - Format(SharePointAccount."Client Id", 0, 4), - SharePointAccount.GetCertificate(SharePointAccount."Certificate Key"), - SharePointAccount.GetCertificatePassword(SharePointAccount."Certificate Password Key"), - Scopes); - end; - - SharePointClient.Initialize(SharePointAccount."SharePoint Url", SharePointAuthorization); - end; - - local procedure PathSeparator(): Text - begin - exit('/'); - end; - - local procedure ShowError(var SharePointClient: Codeunit "SharePoint Client") - var - ErrorOccuredErr: Label 'An error occured.\%1', Comment = '%1 - Error message from sharepoint'; - begin - Error(ErrorOccuredErr, SharePointClient.GetDiagnostics().GetErrorMessage()); - end; - - local procedure GetParentPath(Path: Text) ParentPath: Text - begin - if (Path.TrimEnd(PathSeparator()).Contains(PathSeparator())) then - ParentPath := Path.TrimEnd(PathSeparator()).Substring(1, Path.LastIndexOf(PathSeparator())); - end; - - local procedure GetFileName(Path: Text) FileName: Text - begin - if (Path.TrimEnd(PathSeparator()).Contains(PathSeparator())) then - FileName := Path.TrimEnd(PathSeparator()).Substring(Path.LastIndexOf(PathSeparator()) + 1); - end; - - local procedure InitPath(AccountId: Guid; var Path: Text) - var - SharePointAccount: Record "Ext. SharePoint Account"; - begin - SharePointAccount.Get(AccountId); - Path := CombinePath(SharePointAccount."Base Relative Folder Path", Path); - end; - - local procedure CombinePath(Parent: Text; Child: Text): Text - begin - if Parent = '' then - exit(Child); - - if Child = '' then - exit(Parent); - - if not Parent.EndsWith(PathSeparator()) then - Parent += PathSeparator(); - - exit(Parent + Child); - end; - - local procedure SplitPath(Path: Text; var ParentPath: Text; var FileName: Text) - begin - ParentPath := Path.TrimEnd(PathSeparator()).Substring(1, Path.LastIndexOf(PathSeparator())); - FileName := Path.TrimEnd(PathSeparator()).Substring(Path.LastIndexOf(PathSeparator()) + 1); - end; - [EventSubscriber(ObjectType::Codeunit, Codeunit::"Environment Cleanup", OnClearCompanyConfig, '', false, false)] local procedure EnvironmentCleanup_OnClearCompanyConfig(CompanyName: Text; SourceEnv: Enum "Environment Type"; DestinationEnv: Enum "Environment Type") var diff --git a/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointGraphHelper.Codeunit.al b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointGraphHelper.Codeunit.al new file mode 100644 index 0000000000..c5215a7f87 --- /dev/null +++ b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointGraphHelper.Codeunit.al @@ -0,0 +1,370 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.ExternalFileStorage; + +using System.Integration.Graph.Authorization; +using System.Integration.Sharepoint; +using System.Utilities; + +/// +/// Helper implementation for SharePoint file operations using Microsoft Graph API. +/// This codeunit contains the actual Graph API logic, called by the main connector based on account settings. +/// +codeunit 4581 "Ext. SharePoint Graph Helper" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + Permissions = tabledata "Ext. SharePoint Account" = rimd; + + + var + SharePointGraphClient: Codeunit "SharePoint Graph Client"; + AccountDisabledErr: Label 'The account "%1" is disabled.', Comment = '%1 - Account Name'; + ErrorOccurredErr: Label 'An error occurred.\%1', Comment = '%1 - Error message from Graph API'; + + #region File Operations + + internal procedure ListFiles(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text; FilePaginationData: Codeunit "File Pagination Data"; var TempFileAccountContent: Record "File Account Content" temporary) + var + GraphDriveItem: Record "SharePoint Graph Drive Item"; + OriginalPath: Text; + begin + OriginalPath := Path; + InitPath(SharePointAccount, Path); + InitializeGraphClient(SharePointAccount); + + // List children in the directory + Path := Path.TrimEnd('/'); + if Path = '' then + Path := '/'; + + SharePointGraphClient.GetItemsByPath(Path, GraphDriveItem); + + if GraphDriveItem.FindSet() then + repeat + // Only include files (not folders) + if not GraphDriveItem.IsFolder then begin + TempFileAccountContent.Init(); + TempFileAccountContent.Name := GraphDriveItem.Name; + TempFileAccountContent.Type := TempFileAccountContent.Type::"File"; + TempFileAccountContent."Parent Directory" := CopyStr(OriginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory")); + TempFileAccountContent.Insert(); + end; + until GraphDriveItem.Next() = 0; + + FilePaginationData.SetEndOfListing(true); + end; + + internal procedure GetFile(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text; Stream: InStream) + var + TempBlob: Codeunit "Temp Blob"; + Response: Codeunit "SharePoint Graph Response"; + Content: HttpContent; + begin + InitPath(SharePointAccount, Path); + InitializeGraphClient(SharePointAccount); + + // Use chunked download for all files to handle >150MB files + Response := SharePointGraphClient.DownloadLargeFileByPath(Path, TempBlob); + + if not Response.IsSuccessful() then + ShowError(Response); + + // TempBlob is cleared after the procedure. HttpContent "hack" keeps the data. + Content.WriteFrom(TempBlob.CreateInStream()); + Content.ReadAs(Stream); + end; + + internal procedure CreateFile(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text; Stream: InStream) + var + GraphDriveItem: Record "SharePoint Graph Drive Item"; + Response: Codeunit "SharePoint Graph Response"; + FileName: Text; + FolderPath: Text; + MaxSimpleUploadSize: Integer; + begin + InitPath(SharePointAccount, Path); + InitializeGraphClient(SharePointAccount); + + // Split path into folder and filename + SplitPath(Path, FolderPath, FileName); + + // Use simple upload for files under 4MB, chunked upload for larger files + // Graph API supports simple upload up to 4MB + MaxSimpleUploadSize := 4 * 1024 * 1024; // 4 MB + + if Stream.Length <= MaxSimpleUploadSize then + Response := SharePointGraphClient.UploadFile(FolderPath, FileName, Stream, GraphDriveItem) + else + Response := SharePointGraphClient.UploadLargeFile(FolderPath, FileName, Stream, GraphDriveItem); + + if not Response.IsSuccessful() then + ShowError(Response); + end; + + internal procedure CopyFile(SharePointAccount: Record "Ext. SharePoint Account"; SourcePath: Text; TargetPath: Text) + var + Response: Codeunit "SharePoint Graph Response"; + FileName: Text; + TargetFolderPath: Text; + begin + InitPath(SharePointAccount, SourcePath); + InitPath(SharePointAccount, TargetPath); + InitializeGraphClient(SharePointAccount); + + // Split destination path into folder and filename + SplitPath(TargetPath, TargetFolderPath, FileName); + + // Use native Graph API copy operation + Response := SharePointGraphClient.CopyItemByPath(SourcePath, TargetFolderPath, FileName); + + if not Response.IsSuccessful() then + ShowError(Response); + end; + + internal procedure MoveFile(SharePointAccount: Record "Ext. SharePoint Account"; SourcePath: Text; TargetPath: Text) + var + Response: Codeunit "SharePoint Graph Response"; + FileName: Text; + TargetFolderPath: Text; + begin + InitPath(SharePointAccount, SourcePath); + InitPath(SharePointAccount, TargetPath); + InitializeGraphClient(SharePointAccount); + + // Split destination path into folder and filename + SplitPath(TargetPath, TargetFolderPath, FileName); + + // Use native Graph API move operation + Response := SharePointGraphClient.MoveItemByPath(SourcePath, TargetFolderPath, FileName); + + if not Response.IsSuccessful() then + ShowError(Response); + end; + + internal procedure FileExists(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text): Boolean + var + Response: Codeunit "SharePoint Graph Response"; + Exists: Boolean; + begin + InitPath(SharePointAccount, Path); + InitializeGraphClient(SharePointAccount); + + Response := SharePointGraphClient.ItemExistsByPath(Path, Exists); + + if not Response.IsSuccessful() then + ShowError(Response); + + exit(Exists); + end; + + internal procedure DeleteFile(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text) + var + Response: Codeunit "SharePoint Graph Response"; + begin + InitPath(SharePointAccount, Path); + InitializeGraphClient(SharePointAccount); + + Response := SharePointGraphClient.DeleteItemByPath(Path); + + if not Response.IsSuccessful() then + ShowError(Response); + end; + + #endregion + + #region Directory Operations + + internal procedure ListDirectories(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text; FilePaginationData: Codeunit "File Pagination Data"; var TempFileAccountContent: Record "File Account Content" temporary) + var + TempGraphDriveItem: Record "SharePoint Graph Drive Item" temporary; + OriginalPath: Text; + begin + OriginalPath := Path; + InitPath(SharePointAccount, Path); + InitializeGraphClient(SharePointAccount); + + // List children in the directory + Path := Path.TrimEnd('/'); + if Path = '' then + Path := '/'; + + SharePointGraphClient.GetItemsByPath(Path, TempGraphDriveItem); + + if TempGraphDriveItem.FindSet() then + repeat + // Only include folders + if TempGraphDriveItem.IsFolder then begin + TempFileAccountContent.Init(); + TempFileAccountContent.Name := TempGraphDriveItem.Name; + TempFileAccountContent.Type := TempFileAccountContent.Type::Directory; + TempFileAccountContent."Parent Directory" := CopyStr(OriginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory")); + TempFileAccountContent.Insert(); + end; + until TempGraphDriveItem.Next() = 0; + + FilePaginationData.SetEndOfListing(true); + end; + + internal procedure CreateDirectory(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text) + var + GraphDriveItem: Record "SharePoint Graph Drive Item"; + Response: Codeunit "SharePoint Graph Response"; + ParentPath: Text; + FolderName: Text; + begin + InitPath(SharePointAccount, Path); + InitializeGraphClient(SharePointAccount); + + // Split path into parent path and folder name + SplitPath(Path, ParentPath, FolderName); + + Response := SharePointGraphClient.CreateFolder(ParentPath, FolderName, GraphDriveItem); + + if not Response.IsSuccessful() then + ShowError(Response); + end; + + internal procedure DirectoryExists(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text): Boolean + var + Response: Codeunit "SharePoint Graph Response"; + Exists: Boolean; + begin + InitPath(SharePointAccount, Path); + InitializeGraphClient(SharePointAccount); + + Response := SharePointGraphClient.ItemExistsByPath(Path, Exists); + + if not Response.IsSuccessful() then + ShowError(Response); + + exit(Exists); + end; + + internal procedure DeleteDirectory(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text) + var + Response: Codeunit "SharePoint Graph Response"; + begin + InitPath(SharePointAccount, Path); + InitializeGraphClient(SharePointAccount); + + Response := SharePointGraphClient.DeleteItemByPath(Path); + + if not Response.IsSuccessful() then + ShowError(Response); + end; + + #endregion + + #region Helper Methods + + local procedure InitializeGraphClient(SharePointAccount: Record "Ext. SharePoint Account") + var + GraphAuthorization: Codeunit "Graph Authorization"; + GraphAuthInterface: Interface "Graph Authorization"; + ClientSecret: SecretText; + Certificate: SecretText; + CertificatePassword: SecretText; + Scopes: List of [Text]; + begin + // Get and validate account + if SharePointAccount.Disabled then + Error(AccountDisabledErr, SharePointAccount.Name); + + // Add required SharePoint scopes + Scopes.Add('https://graph.microsoft.com/.default'); + + // Create authorization based on authentication type + case SharePointAccount."Authentication Type" of + SharePointAccount."Authentication Type"::"Client Secret": + begin + ClientSecret := SharePointAccount.GetClientSecret(SharePointAccount."Client Secret Key"); + GraphAuthInterface := GraphAuthorization.CreateAuthorizationWithClientCredentials( + Format(SharePointAccount."Tenant Id", 0, 4), + Format(SharePointAccount."Client Id", 0, 4), + ClientSecret, + Scopes); + end; + SharePointAccount."Authentication Type"::Certificate: + begin + Certificate := SharePointAccount.GetCertificate(SharePointAccount."Certificate Key"); + CertificatePassword := SharePointAccount.GetCertificatePassword(SharePointAccount."Certificate Password Key"); + GraphAuthInterface := GraphAuthorization.CreateAuthorizationWithClientCredentials( + Format(SharePointAccount."Tenant Id", 0, 4), + Format(SharePointAccount."Client Id", 0, 4), + Certificate, + CertificatePassword, + Scopes); + end; + end; + + // Initialize SharePoint Graph Client with Site URL and authorization + SharePointGraphClient.Initialize(SharePointAccount."SharePoint Url", GraphAuthInterface); + end; + + local procedure ShowError(Response: Codeunit "SharePoint Graph Response") + begin + Error(ErrorOccurredErr, Response.GetError()); + end; + + local procedure SplitPath(FullPath: Text; var FolderPath: Text; var ItemName: Text) + var + LastSlashPos: Integer; + begin + // Find the last slash to split path + LastSlashPos := StrPos(ReverseString(FullPath), '/'); + + if LastSlashPos = 0 then begin + // No slash found - item is in root + FolderPath := '/'; + ItemName := FullPath; + end else begin + LastSlashPos := StrLen(FullPath) - LastSlashPos + 1; + FolderPath := CopyStr(FullPath, 1, LastSlashPos - 1); + ItemName := CopyStr(FullPath, LastSlashPos + 1); + + if FolderPath = '' then + FolderPath := '/'; + end; + end; + + local procedure ReverseString(InputString: Text): Text + var + ReversedString: Text; + i: Integer; + begin + for i := StrLen(InputString) downto 1 do + ReversedString := ReversedString + CopyStr(InputString, i, 1); + exit(ReversedString); + end; + + local procedure PathSeparator(): Text + begin + exit('/'); + end; + + local procedure InitPath(SharePointAccount: Record "Ext. SharePoint Account"; var Path: Text) + begin + Path := CombinePath(SharePointAccount."Base Relative Folder Path", Path); + end; + + local procedure CombinePath(Parent: Text; Child: Text): Text + begin + if Parent = '' then + exit(Child); + + if Child = '' then + exit(Parent); + + if not Parent.EndsWith(PathSeparator()) then + Parent += PathSeparator(); + + exit(Parent + Child); + end; + + #endregion +} diff --git a/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointRestHelper.Codeunit.al b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointRestHelper.Codeunit.al new file mode 100644 index 0000000000..27f849ae29 --- /dev/null +++ b/src/Apps/W1/External File Storage - SharePoint Connector/App/src/ExtSharePointRestHelper.Codeunit.al @@ -0,0 +1,282 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.ExternalFileStorage; + +using System.Integration.Sharepoint; +using System.Utilities; + +/// +/// Helper implementation for SharePoint file operations using SharePoint REST API. +/// This codeunit contains the actual REST API logic, called by the main connector based on account settings. +/// +codeunit 4582 "Ext. SharePoint REST Helper" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + Permissions = tabledata "Ext. SharePoint Account" = rimd; + + + #region File Operations + + internal procedure ListFiles(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text; FilePaginationData: Codeunit "File Pagination Data"; var TempFileAccountContent: Record "File Account Content" temporary) + var + SharePointFile: Record "SharePoint File"; + SharePointClient: Codeunit "SharePoint Client"; + OriginalPath: Text; + begin + OriginalPath := Path; + InitPath(SharePointAccount, Path); + InitSharePointClient(SharePointAccount, SharePointClient); + if not SharePointClient.GetFolderFilesByServerRelativeUrl(Path, SharePointFile) then + ShowError(SharePointClient); + + FilePaginationData.SetEndOfListing(true); + + if not SharePointFile.FindSet() then + exit; + + repeat + TempFileAccountContent.Init(); + TempFileAccountContent.Name := SharePointFile.Name; + TempFileAccountContent.Type := TempFileAccountContent.Type::"File"; + TempFileAccountContent."Parent Directory" := CopyStr(OriginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory")); + TempFileAccountContent.Insert(); + until SharePointFile.Next() = 0; + end; + + internal procedure GetFile(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text; Stream: InStream) + var + SharePointClient: Codeunit "SharePoint Client"; + Content: HttpContent; + TempBlobStream: InStream; + begin + InitPath(SharePointAccount, Path); + InitSharePointClient(SharePointAccount, SharePointClient); + + if not SharePointClient.DownloadFileContentByServerRelativeUrl(Path, TempBlobStream) then + ShowError(SharePointClient); + + // Platform fix: For some reason the Stream from DownloadFileContentByServerRelativeUrl dies after leaving the interface + Content.WriteFrom(TempBlobStream); + Content.ReadAs(Stream); + end; + + internal procedure CreateFile(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text; Stream: InStream) + var + SharePointFile: Record "SharePoint File"; + SharePointClient: Codeunit "SharePoint Client"; + ParentPath, FileName : Text; + begin + InitPath(SharePointAccount, Path); + InitSharePointClient(SharePointAccount, SharePointClient); + SplitPath(Path, ParentPath, FileName); + if SharePointClient.AddFileToFolder(ParentPath, FileName, Stream, SharePointFile, false) then + exit; + + ShowError(SharePointClient); + end; + + internal procedure CopyFile(SharePointAccount: Record "Ext. SharePoint Account"; SourcePath: Text; TargetPath: Text) + var + TempBlob: Codeunit "Temp Blob"; + Stream: InStream; + begin + TempBlob.CreateInStream(Stream); + + GetFile(SharePointAccount, SourcePath, Stream); + CreateFile(SharePointAccount, TargetPath, Stream); + end; + + internal procedure MoveFile(SharePointAccount: Record "Ext. SharePoint Account"; SourcePath: Text; TargetPath: Text) + var + Stream: InStream; + begin + GetFile(SharePointAccount, SourcePath, Stream); + CreateFile(SharePointAccount, TargetPath, Stream); + DeleteFile(SharePointAccount, SourcePath); + end; + + internal procedure FileExists(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text): Boolean + var + SharePointFile: Record "SharePoint File"; + SharePointClient: Codeunit "SharePoint Client"; + begin + InitPath(SharePointAccount, Path); + InitSharePointClient(SharePointAccount, SharePointClient); + if not SharePointClient.GetFolderFilesByServerRelativeUrl(GetParentPath(Path), SharePointFile) then + ShowError(SharePointClient); + + SharePointFile.SetRange(Name, GetFileName(Path)); + exit(not SharePointFile.IsEmpty()); + end; + + internal procedure DeleteFile(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text) + var + SharePointClient: Codeunit "SharePoint Client"; + begin + InitPath(SharePointAccount, Path); + InitSharePointClient(SharePointAccount, SharePointClient); + if SharePointClient.DeleteFileByServerRelativeUrl(Path) then + exit; + + ShowError(SharePointClient); + end; + + #endregion + + #region Directory Operations + + internal procedure ListDirectories(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text; FilePaginationData: Codeunit "File Pagination Data"; var TempFileAccountContent: Record "File Account Content" temporary) + var + SharePointFolder: Record "SharePoint Folder"; + SharePointClient: Codeunit "SharePoint Client"; + OriginalPath: Text; + begin + OriginalPath := Path; + InitPath(SharePointAccount, Path); + InitSharePointClient(SharePointAccount, SharePointClient); + if not SharePointClient.GetSubFoldersByServerRelativeUrl(Path, SharePointFolder) then + ShowError(SharePointClient); + + FilePaginationData.SetEndOfListing(true); + + if not SharePointFolder.FindSet() then + exit; + + repeat + TempFileAccountContent.Init(); + TempFileAccountContent.Name := SharePointFolder.Name; + TempFileAccountContent.Type := TempFileAccountContent.Type::Directory; + TempFileAccountContent."Parent Directory" := CopyStr(OriginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory")); + TempFileAccountContent.Insert(); + until SharePointFolder.Next() = 0; + end; + + internal procedure CreateDirectory(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text) + var + SharePointFolder: Record "SharePoint Folder"; + SharePointClient: Codeunit "SharePoint Client"; + begin + InitPath(SharePointAccount, Path); + InitSharePointClient(SharePointAccount, SharePointClient); + if SharePointClient.CreateFolder(Path, SharePointFolder) then + exit; + + ShowError(SharePointClient); + end; + + internal procedure DirectoryExists(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text) Result: Boolean + var + SharePointClient: Codeunit "SharePoint Client"; + begin + InitPath(SharePointAccount, Path); + InitSharePointClient(SharePointAccount, SharePointClient); + + Result := SharePointClient.FolderExistsByServerRelativeUrl(Path); + + if not SharePointClient.GetDiagnostics().IsSuccessStatusCode() then + ShowError(SharePointClient); + end; + + internal procedure DeleteDirectory(SharePointAccount: Record "Ext. SharePoint Account"; Path: Text) + var + SharePointClient: Codeunit "SharePoint Client"; + begin + InitPath(SharePointAccount, Path); + InitSharePointClient(SharePointAccount, SharePointClient); + if SharePointClient.DeleteFolderByServerRelativeUrl(Path) then + exit; + + ShowError(SharePointClient); + end; + + #endregion + + #region Helper Methods + + local procedure InitSharePointClient(SharePointAccount: Record "Ext. SharePoint Account"; var SharePointClient: Codeunit "SharePoint Client") + var + SharePointAuth: Codeunit "SharePoint Auth."; + SharePointAuthorization: Interface "SharePoint Authorization"; + Scopes: List of [Text]; + AccountDisabledErr: Label 'The account "%1" is disabled.', Comment = '%1 - Account Name'; + begin + if SharePointAccount.Disabled then + Error(AccountDisabledErr, SharePointAccount.Name); + + Scopes.Add('00000003-0000-0ff1-ce00-000000000000/.default'); + + case SharePointAccount."Authentication Type" of + Enum::"Ext. SharePoint Auth Type"::"Client Secret": + SharePointAuthorization := SharePointAuth.CreateAuthorizationCode( + Format(SharePointAccount."Tenant Id", 0, 4), + Format(SharePointAccount."Client Id", 0, 4), + SharePointAccount.GetClientSecret(SharePointAccount."Client Secret Key"), + Scopes); + Enum::"Ext. SharePoint Auth Type"::Certificate: + SharePointAuthorization := SharePointAuth.CreateClientCredentials( + Format(SharePointAccount."Tenant Id", 0, 4), + Format(SharePointAccount."Client Id", 0, 4), + SharePointAccount.GetCertificate(SharePointAccount."Certificate Key"), + SharePointAccount.GetCertificatePassword(SharePointAccount."Certificate Password Key"), + Scopes); + end; + + SharePointClient.Initialize(SharePointAccount."SharePoint Url", SharePointAuthorization); + end; + + local procedure PathSeparator(): Text + begin + exit('/'); + end; + + local procedure ShowError(var SharePointClient: Codeunit "SharePoint Client") + var + ErrorOccurredErr: Label 'An error occurred.\%1', Comment = '%1 - Error message from SharePoint'; + begin + Error(ErrorOccurredErr, SharePointClient.GetDiagnostics().GetErrorMessage()); + end; + + local procedure GetParentPath(Path: Text) ParentPath: Text + begin + if (Path.TrimEnd(PathSeparator()).Contains(PathSeparator())) then + ParentPath := Path.TrimEnd(PathSeparator()).Substring(1, Path.LastIndexOf(PathSeparator())); + end; + + local procedure GetFileName(Path: Text) FileName: Text + begin + if (Path.TrimEnd(PathSeparator()).Contains(PathSeparator())) then + FileName := Path.TrimEnd(PathSeparator()).Substring(Path.LastIndexOf(PathSeparator()) + 1); + end; + + local procedure InitPath(SharePointAccount: Record "Ext. SharePoint Account"; var Path: Text) + begin + Path := CombinePath(SharePointAccount."Base Relative Folder Path", Path); + end; + + local procedure CombinePath(Parent: Text; Child: Text): Text + begin + if Parent = '' then + exit(Child); + + if Child = '' then + exit(Parent); + + if not Parent.EndsWith(PathSeparator()) then + Parent += PathSeparator(); + + exit(Parent + Child); + end; + + local procedure SplitPath(Path: Text; var ParentPath: Text; var FileName: Text) + begin + ParentPath := Path.TrimEnd(PathSeparator()).Substring(1, Path.LastIndexOf(PathSeparator())); + FileName := Path.TrimEnd(PathSeparator()).Substring(Path.LastIndexOf(PathSeparator()) + 1); + end; + + #endregion +} diff --git a/src/System Application/App/MicrosoftGraph/src/Authorization/GraphAuthClientCredentials.Codeunit.al b/src/System Application/App/MicrosoftGraph/src/Authorization/GraphAuthClientCredentials.Codeunit.al index 2cc65ce240..d2a155e973 100644 --- a/src/System Application/App/MicrosoftGraph/src/Authorization/GraphAuthClientCredentials.Codeunit.al +++ b/src/System Application/App/MicrosoftGraph/src/Authorization/GraphAuthClientCredentials.Codeunit.al @@ -36,7 +36,7 @@ codeunit 9357 "Graph Auth. Client Credentials" implements "Graph Authorization" ClientCredentialsType := ClientCredentialsType::Certificate; AadTenantId := NewAadTenantId; ClientId := NewClientId; - Certificate := Certificate; + Certificate := NewCertificate; CertificatePassword := NewCertificatePassword; Scopes := NewScopes; end;