diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/Entitlements/ExtSFTPConnector.Entitlement.al b/src/Apps/W1/External File Storage - SFTP Connector/App/Entitlements/ExtSFTPConnector.Entitlement.al
new file mode 100644
index 0000000000..0c213b4471
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/Entitlements/ExtSFTPConnector.Entitlement.al
@@ -0,0 +1,13 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+entitlement "Ext. SFTP Connector"
+{
+
+ ObjectEntitlements = "Ext. SFTP - Edit";
+ Type = Implicit;
+}
\ No newline at end of file
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/ExtensionLogo.png b/src/Apps/W1/External File Storage - SFTP Connector/App/ExtensionLogo.png
new file mode 100644
index 0000000000..30941b354f
Binary files /dev/null and b/src/Apps/W1/External File Storage - SFTP Connector/App/ExtensionLogo.png differ
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/README.md b/src/Apps/W1/External File Storage - SFTP Connector/App/README.md
new file mode 100644
index 0000000000..fcd9ecd03b
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/README.md
@@ -0,0 +1,2 @@
+# External File Storage - SFTP Connector
+This connector allows access to SFTP Server Files and Folder.
\ No newline at end of file
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/app.json b/src/Apps/W1/External File Storage - SFTP Connector/App/app.json
new file mode 100644
index 0000000000..fe3bd58ea6
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/app.json
@@ -0,0 +1,43 @@
+{
+ "id": "e0df20ef-75a2-4fae-8e3a-88140ab29507",
+ "name": "External File Storage - SFTP Connector",
+ "publisher": "Microsoft",
+ "brief": "Enables file and folder operations for SFTP folders and files via the External File Storage Module with Business Central.",
+ "description": "This app enables file and folder operations for SFTP folders and files via the External File Storage Module with Business Central.",
+ "version": "28.0.0.0",
+ "privacyStatement": "https://go.microsoft.com/fwlink/?linkid=724009",
+ "EULA": "https://go.microsoft.com/fwlink/?linkid=2009120",
+ "help": "https://go.microsoft.com/fwlink/?linkid=2134520",
+ "url": "https://go.microsoft.com/fwlink/?linkid=724011",
+ "logo": "ExtensionLogo.png",
+ "application": "28.0.0.0",
+ "platform": "28.0.0.0",
+ "internalsVisibleTo": [
+ {
+ "id": "87c3fa98-904d-452d-95fe-5de2c7f0b624",
+ "name": "External File Storage - SFTP Connector Tests",
+ "publisher": "Microsoft"
+ }
+ ],
+ "dependencies": [],
+ "screenshots": [],
+ "idRanges": [
+ {
+ "from": 4621,
+ "to": 4629
+ }
+ ],
+ "resourceExposurePolicy": {
+ "allowDebugging": true,
+ "allowDownloadingSource": true,
+ "includeSourceInSymbolFile": true
+ },
+ "contextSensitiveHelpUrl": "https://go.microsoft.com/fwlink/?linkid=2134520",
+ "resourceFolders": [
+ "data"
+ ],
+ "target": "Cloud",
+ "features": [
+ "TranslationFile"
+ ]
+}
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/data/connector-logo.png b/src/Apps/W1/External File Storage - SFTP Connector/App/data/connector-logo.png
new file mode 100644
index 0000000000..3e993a7494
Binary files /dev/null and b/src/Apps/W1/External File Storage - SFTP Connector/App/data/connector-logo.png differ
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPEdit.PermissionSet.al b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPEdit.PermissionSet.al
new file mode 100644
index 0000000000..b9b754a5dd
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPEdit.PermissionSet.al
@@ -0,0 +1,17 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+permissionset 4621 "Ext. SFTP - Edit"
+{
+ Access = Public;
+ Assignable = false;
+ Caption = 'SFTP - Edit';
+ IncludedPermissionSets = "Ext. SFTP - Read";
+
+ Permissions =
+ tabledata "Ext. SFTP Account" = imd;
+}
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPObjects.PermissionSet.al b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPObjects.PermissionSet.al
new file mode 100644
index 0000000000..e9c9046ff3
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPObjects.PermissionSet.al
@@ -0,0 +1,17 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+permissionset 4622 "Ext. SFTP - Objects"
+{
+ Access = Public;
+ Assignable = false;
+ Caption = 'SFTP - Objects';
+ Permissions =
+ table "Ext. SFTP Account" = X,
+ page "Ext. SFTP Account Wizard" = X,
+ page "Ext. SFTP Account" = X;
+}
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPRead.PermissionSet.al b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPRead.PermissionSet.al
new file mode 100644
index 0000000000..86482d2513
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPRead.PermissionSet.al
@@ -0,0 +1,17 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+permissionset 4623 "Ext. SFTP - Read"
+{
+ Access = Public;
+ Assignable = false;
+ Caption = 'SFTP - Read';
+ IncludedPermissionSets = "Ext. SFTP - Objects";
+
+ Permissions =
+ tabledata "Ext. SFTP Account" = r;
+}
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageAdminExtSFTP.PermissionSetExt.al b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageAdminExtSFTP.PermissionSetExt.al
new file mode 100644
index 0000000000..ea2008d1cf
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageAdminExtSFTP.PermissionSetExt.al
@@ -0,0 +1,11 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+permissionsetextension 4621 "File Storage - Admin - Ext. SFTP" extends "File Storage - Admin"
+{
+ IncludedPermissionSets = "Ext. SFTP - Edit";
+}
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageEditExtSFTP.PermissionSetExt.al b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageEditExtSFTP.PermissionSetExt.al
new file mode 100644
index 0000000000..d0be734af5
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageEditExtSFTP.PermissionSetExt.al
@@ -0,0 +1,11 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+permissionsetextension 4622 "File Storage - Edit - Ext. SFTP" extends "File Storage - Edit"
+{
+ IncludedPermissionSets = "Ext. SFTP - Read";
+}
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al
new file mode 100644
index 0000000000..494c526fa5
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al
@@ -0,0 +1,161 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+///
+/// Displays an account that was registered via the SFTP connector.
+///
+page 4621 "Ext. SFTP Account"
+{
+ ApplicationArea = All;
+ Caption = 'SFTP Account';
+ DataCaptionExpression = Rec.Name;
+ Extensible = false;
+ InsertAllowed = false;
+ PageType = Card;
+ Permissions = tabledata "Ext. SFTP Account" = rimd;
+ SourceTable = "Ext. SFTP Account";
+ UsageCategory = None;
+
+ layout
+ {
+ area(Content)
+ {
+ field(NameField; Rec.Name)
+ {
+ NotBlank = true;
+ ShowMandatory = true;
+ }
+ field(Hostname; Rec.Hostname) { }
+ field(Port; Rec.Port) { }
+ field(Fingerprints; Rec.Fingerprints) { }
+ field("Base Relative Folder Path"; Rec."Base Relative Folder Path") { }
+ field("Authentication Type"; Rec."Authentication Type")
+ {
+ trigger OnValidate()
+ begin
+ MaskSensitiveFields();
+ UpdateAuthTypeVisibility();
+ CurrPage.Update(true);
+ end;
+ }
+ field(Username; Rec.Username) { }
+ group(Credentials)
+ {
+ Caption = 'Credentials';
+ Editable = PageEditable;
+
+ group(SFTPPasswordGroup)
+ {
+ ShowCaption = false;
+ Visible = PasswordVisible;
+
+ field(PasswordField; Password)
+ {
+ Caption = 'Password';
+ ExtendedDatatype = Masked;
+ ToolTip = 'Specifies the Password to access the SFTP Server.';
+ trigger OnValidate()
+ begin
+ Rec.SetPassword(Password);
+ end;
+ }
+ }
+ group(SFTPCertificateGroup)
+ {
+ ShowCaption = false;
+ Visible = CertificateVisible;
+
+ field(CertificateUploadStatus; CertificateStatusText)
+ {
+ Caption = 'Certificate';
+ Editable = false;
+ ToolTip = 'Specifies the key file used for authentication. Click here to upload a key file (.pk, .ppk, or .pub).';
+
+ trigger OnDrillDown()
+ begin
+ Certificate := Rec.UploadCertificateFile();
+ Rec.SetCertificate(Certificate);
+ UpdateCertificateStatus();
+ end;
+ }
+
+ field(CertificatePasswordField; CertificatePassword)
+ {
+ Caption = 'Certificate Password';
+ ExtendedDatatype = Masked;
+ ToolTip = 'Specifies the password used to protect the private key in the certificate. Leave empty if the certificate is not password-protected.';
+
+ trigger OnValidate()
+ begin
+ Rec.SetCertificatePassword(CertificatePassword);
+ end;
+ }
+ }
+ }
+ field(Disabled; Rec.Disabled) { }
+ }
+ }
+
+ var
+ PageEditable: Boolean;
+ PasswordVisible: Boolean;
+ CertificateVisible: Boolean;
+ [NonDebuggable]
+ Password: Text;
+ [NonDebuggable]
+ CertificatePassword, Certificate : Text;
+ CertificateStatusText: Text;
+
+ trigger OnOpenPage()
+ begin
+ Rec.SetCurrentKey(Name);
+ UpdateAuthTypeVisibility();
+ end;
+
+ trigger OnAfterGetCurrRecord()
+ begin
+ PageEditable := CurrPage.Editable();
+
+ MaskSensitiveFields();
+ UpdateAuthTypeVisibility();
+ UpdateCertificateStatus();
+ end;
+
+ local procedure MaskSensitiveFields()
+ begin
+ Clear(Password);
+ Clear(Certificate);
+ Clear(CertificatePassword);
+
+ if not IsNullGuid(Rec."Password Key") then
+ Password := '***';
+
+ if not IsNullGuid(Rec."Certificate Password Key") then
+ CertificatePassword := '***';
+ end;
+
+ local procedure UpdateAuthTypeVisibility()
+ begin
+ PasswordVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Password;
+ CertificateVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Certificate;
+
+
+ if CertificateVisible then
+ UpdateCertificateStatus();
+ end;
+
+ local procedure UpdateCertificateStatus()
+ var
+ NoCertificateLbl: Label 'No certificate (click to upload)';
+ CertificateUploadedLbl: Label 'Certificate uploaded (click to change)';
+ begin
+ if IsNullGuid(Rec."Certificate Key") then
+ CertificateStatusText := NoCertificateLbl
+ else
+ CertificateStatusText := CertificateUploadedLbl;
+ end;
+}
\ No newline at end of file
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al
new file mode 100644
index 0000000000..49744be512
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al
@@ -0,0 +1,239 @@
+// ------------------------------------------------------------------------------------------------
+// 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.Text;
+using System.Utilities;
+
+///
+/// Holds the information for all file accounts that are registered via the SFTP connector
+///
+table 4621 "Ext. SFTP Account"
+{
+ Caption = 'SFTP Account';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; Id; Guid)
+ {
+ AllowInCustomizations = Never;
+ Caption = 'Primary Key';
+ DataClassification = SystemMetadata;
+ }
+ field(2; Name; Text[250])
+ {
+ Caption = 'Account Name';
+ ToolTip = 'Specifies a descriptive name for this SFTP storage account connection.';
+ }
+ field(4; Hostname; Text[2048])
+ {
+ Caption = 'Hostname';
+ ToolTip = 'Specifies the hostname of the SFTP server.';
+ }
+ field(5; Port; Integer)
+ {
+ Caption = 'Port';
+ ToolTip = 'Specifies the port number of the SFTP server.';
+ InitValue = 22;
+ MinValue = 1;
+ MaxValue = 65535;
+ }
+ field(6; "Base Relative Folder Path"; Text[2048])
+ {
+ Caption = 'Base Relative Folder Path';
+ ToolTip = 'Specifies the base folder path on the SFTP server. Use an absolute path starting with / (e.g., /home/user/files or /data/uploads). All file operations will be relative to this path.';
+ }
+ field(7; Username; Text[256])
+ {
+ Access = Internal;
+ Caption = 'Username';
+ ToolTip = 'Specifies the username for authenticating with the SFTP server.';
+ }
+ field(9; "Password Key"; Guid)
+ {
+ Access = Internal;
+ DataClassification = SystemMetadata;
+ }
+ field(10; Disabled; Boolean)
+ {
+ Caption = 'Disabled';
+ ToolTip = 'Specifies if the account is disabled. Accounts are automatically disabled when a sandbox environment is created from production.';
+ }
+ field(11; "Certificate Key"; Guid)
+ {
+ Access = Internal;
+ DataClassification = SystemMetadata;
+ }
+ field(12; "Certificate Password Key"; Guid)
+ {
+ Access = Internal;
+ DataClassification = SystemMetadata;
+ }
+ field(13; Fingerprints; Text[1024])
+ {
+ Caption = 'Fingerprints';
+ ToolTip = 'Specifies the known host fingerprints for this SFTP account. Each fingerprint must be prefixed with sha256: or md5:. Multiple fingerprints can be separated with commas.';
+ Access = Internal;
+ DataClassification = SystemMetadata;
+ }
+ field(14; "Authentication Type"; Enum "Ext. SFTP Auth Type")
+ {
+ Caption = 'Authentication Type';
+ ToolTip = 'Specifies the authentication method used for this SFTP account. Password uses username and password authentication. Certificate uses SSH key-based authentication.';
+ InitValue = Password;
+ }
+ }
+
+ keys
+ {
+ key(PK; Id)
+ {
+ Clustered = true;
+ }
+ }
+
+ var
+ UnableToGetPasswordMsg: Label 'Unable to get SFTP Account Password.';
+ UnableToSetPasswordMsg: Label 'Unable to set SFTP Password.';
+ UnableToGetCertificateMsg: Label 'Unable to get SFTP Certificate.';
+ UnableToSetCertificateMsg: Label 'Unable to set SFTP Certificate.';
+ UnableToGetCertificatePasswordMsg: Label 'Unable to get SFTP Account Certificate Password.';
+ UnableToSetCertificatePasswordMsg: Label 'Unable to set SFTP Certificate Password.';
+
+ trigger OnDelete()
+ begin
+ TryDeleteIsolatedStorageValue(Rec."Password Key");
+ TryDeleteIsolatedStorageValue(Rec."Certificate Key");
+ TryDeleteIsolatedStorageValue(Rec."Certificate Password Key");
+ end;
+
+ internal procedure SetPassword(Password: SecretText)
+ begin
+ if IsNullGuid(Rec."Password Key") then
+ Rec."Password Key" := CreateGuid();
+
+ SetIsolatedStorageValue(Rec."Password Key", Password, UnableToSetPasswordMsg);
+
+ // When setting password, clear certificate authentication
+ // as only one authentication method can be active
+ ClearCertificateAuthentication();
+ end;
+
+ local procedure ClearCertificateAuthentication()
+ begin
+ if not IsNullGuid(Rec."Certificate Key") then begin
+ TryDeleteIsolatedStorageValue(Rec."Certificate Key");
+ Clear(Rec."Certificate Key");
+ end;
+
+ if not IsNullGuid(Rec."Certificate Password Key") then begin
+ TryDeleteIsolatedStorageValue(Rec."Certificate Password Key");
+ Clear(Rec."Certificate Password Key");
+ end;
+ end;
+
+ internal procedure GetPassword(PasswordKey: Guid): SecretText
+ begin
+ exit(GetIsolatedStorageValue(PasswordKey, UnableToGetPasswordMsg));
+ end;
+
+ [NonDebuggable]
+ internal procedure SetCertificate(Certificate: Text)
+ begin
+ if IsNullGuid(Rec."Certificate Key") then
+ Rec."Certificate Key" := CreateGuid();
+
+ if not IsolatedStorage.Set(Format(Rec."Certificate Key"), Certificate, DataScope::Company) then
+ Error(UnableToSetCertificateMsg);
+
+ // When setting certificate, clear client secret authentication
+ // as only one authentication method can be active
+ ClearPasswordAuthentication();
+ end;
+
+ local procedure ClearPasswordAuthentication()
+ begin
+ if IsNullGuid(Rec."Password Key") then
+ exit;
+
+ TryDeleteIsolatedStorageValue(Rec."Password Key");
+ Clear(Rec."Password Key");
+ end;
+
+ [NonDebuggable]
+ internal procedure GetCertificate(CertificateKey: Guid) TempBlob: Codeunit "Temp Blob"
+ var
+ Base64Convert: Codeunit "Base64 Convert";
+ CertificateBase64: Text;
+ Stream: OutStream;
+ begin
+ if not IsolatedStorage.Get(Format(CertificateKey), DataScope::Company, CertificateBase64) then
+ Error(UnableToGetCertificateMsg);
+
+ TempBlob.CreateOutStream(Stream);
+ Base64Convert.FromBase64(CertificateBase64, Stream);
+ end;
+
+ internal procedure SetCertificatePassword(CertificatePassword: SecretText)
+ begin
+ if CertificatePassword.IsEmpty() then begin
+ TryDeleteIsolatedStorageValue(Rec."Certificate Password Key");
+ Clear(Rec."Certificate Password Key");
+ exit;
+ end;
+
+ if IsNullGuid(Rec."Certificate Password Key") then
+ Rec."Certificate Password Key" := CreateGuid();
+
+ SetIsolatedStorageValue(Rec."Certificate Password Key", CertificatePassword, UnableToSetCertificatePasswordMsg);
+ end;
+
+ internal procedure GetCertificatePassword(CertificatePasswordKey: Guid): SecretText
+ begin
+ exit(GetIsolatedStorageValue(CertificatePasswordKey, UnableToGetCertificatePasswordMsg));
+ end;
+
+ local procedure TryDeleteIsolatedStorageValue(StorageKey: Guid)
+ begin
+ if IsNullGuid(StorageKey) then
+ exit;
+
+ if not IsolatedStorage.Contains(Format(StorageKey), DataScope::Company) then
+ exit;
+
+ IsolatedStorage.Delete(StorageKey, DataScope::Company);
+ end;
+
+ local procedure SetIsolatedStorageValue(StorageKey: Guid; Value: SecretText; ErrorMessage: Text)
+ begin
+ if not IsolatedStorage.Set(Format(StorageKey), Value, DataScope::Company) then
+ Error(ErrorMessage);
+ end;
+
+ local procedure GetIsolatedStorageValue(StorageKey: Guid; ErrorMessage: Text) Value: SecretText
+ begin
+ if not IsolatedStorage.Get(Format(StorageKey), DataScope::Company, Value) then
+ Error(ErrorMessage);
+ end;
+
+ internal procedure UploadCertificateFile() CertificateBase64: Text
+ var
+ Base64Convert: Codeunit System.Text."Base64 Convert";
+ UploadResult: Boolean;
+ InStr: InStream;
+ CertificateFilterTxt: Label 'Key Files (*.pk;*.ppk;*.pub)|*.pk;*.ppk;*.pub|All Files (*.*)|*.*';
+ FileNotUploadedErr: Label 'Key file was not uploaded.';
+ begin
+ UploadResult := UploadIntoStream(CertificateFilterTxt, InStr);
+
+ if not UploadResult then
+ Error(FileNotUploadedErr);
+
+ CertificateBase64 := Base64Convert.ToBase64(InStr);
+ exit(CertificateBase64);
+ end;
+}
\ No newline at end of file
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al
new file mode 100644
index 0000000000..74696cdb3e
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al
@@ -0,0 +1,243 @@
+// ------------------------------------------------------------------------------------------------
+// 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.Environment;
+
+///
+/// Displays an account that is being registered via the SFTP connector.
+///
+page 4622 "Ext. SFTP Account Wizard"
+{
+ ApplicationArea = All;
+ Caption = 'Setup SFTP Account';
+ Editable = true;
+ Extensible = false;
+ PageType = NavigatePage;
+ Permissions = tabledata "Ext. SFTP Account" = rimd;
+ SourceTable = "Ext. SFTP Account";
+ SourceTableTemporary = true;
+
+ layout
+ {
+ area(Content)
+ {
+ group(TopBanner)
+ {
+ Editable = false;
+ ShowCaption = false;
+ Visible = TopBannerVisible;
+ field(NotDoneIcon; MediaResources."Media Reference")
+ {
+ Editable = false;
+ ShowCaption = false;
+ ToolTip = ' ', Locked = true;
+ }
+ }
+
+ field(NameField; Rec.Name)
+ {
+ Caption = 'Account Name';
+ NotBlank = true;
+ ShowMandatory = true;
+ ToolTip = 'Specifies a descriptive name for this SFTP storage account connection.';
+
+ trigger OnValidate()
+ begin
+ IsNextEnabled := ConnectorImpl.IsAccountValid(Rec);
+ end;
+ }
+ field(Hostname; Rec.Hostname)
+ {
+ NotBlank = true;
+ ShowMandatory = true;
+
+ trigger OnValidate()
+ begin
+ IsNextEnabled := ConnectorImpl.IsAccountValid(Rec);
+ end;
+ }
+ field(Port; Rec.Port)
+ {
+ NotBlank = true;
+ ShowMandatory = true;
+
+ trigger OnValidate()
+ begin
+ IsNextEnabled := ConnectorImpl.IsAccountValid(Rec);
+ end;
+ }
+ field(Fingerprints; Rec.Fingerprints)
+ {
+ Caption = 'Fingerprints';
+ ShowMandatory = true;
+
+ trigger OnValidate()
+ begin
+ IsNextEnabled := ConnectorImpl.IsAccountValid(Rec);
+ end;
+ }
+ field("Authentication Type"; Rec."Authentication Type")
+ {
+ ToolTip = 'Specifies the authentication method used for this SFTP account. Password uses username and password authentication. Certificate uses SSH key-based authentication.';
+ trigger OnValidate()
+ begin
+ UpdateAuthTypeVisibility();
+ IsNextEnabled := ConnectorImpl.IsAccountValid(Rec);
+ end;
+ }
+ field(Username; Rec.Username)
+ {
+ NotBlank = true;
+ ShowMandatory = true;
+
+ trigger OnValidate()
+ begin
+ IsNextEnabled := ConnectorImpl.IsAccountValid(Rec);
+ end;
+ }
+ group(PasswordGroup)
+ {
+ ShowCaption = false;
+ Visible = PasswordVisible;
+
+ field(Password; Password)
+ {
+ Caption = 'Password';
+ ExtendedDatatype = Masked;
+ ShowMandatory = true;
+ ToolTip = 'Specifies the Password to access the SFTP Server.';
+ }
+ }
+ group(CertificateGroup)
+ {
+ ShowCaption = false;
+ Visible = CertificateVisible;
+
+ field(CertificateUploadStatus; CertificateStatusText)
+ {
+ Caption = 'Certificate';
+ Editable = false;
+ ShowMandatory = true;
+ ToolTip = 'Specifies the key file used for authentication. Click here to upload a key file (.pk, .ppk, or .pub).';
+
+ trigger OnDrillDown()
+ begin
+ Certificate := Rec.UploadCertificateFile();
+ UpdateCertificateStatus();
+ IsNextEnabled := ConnectorImpl.IsAccountValid(Rec);
+ end;
+ }
+
+ field(CertificatePasswordField; CertificatePassword)
+ {
+ Caption = 'Certificate Password';
+ ExtendedDatatype = Masked;
+ ShowMandatory = false;
+ ToolTip = 'Specifies the password used to protect the private key in the certificate. Leave empty if the certificate is not password-protected.';
+ }
+ }
+
+ field("Base Relative Folder Path"; Rec."Base Relative Folder Path")
+ {
+ ShowMandatory = true;
+
+ trigger OnValidate()
+ begin
+ IsNextEnabled := ConnectorImpl.IsAccountValid(Rec);
+ end;
+ }
+ }
+ }
+
+ actions
+ {
+ area(processing)
+ {
+ action(Back)
+ {
+ Caption = 'Back';
+ Image = Cancel;
+ InFooterBar = true;
+ ToolTip = 'Move to previous step.';
+
+ trigger OnAction()
+ begin
+ CurrPage.Close();
+ end;
+ }
+ action(Next)
+ {
+ Caption = 'Next';
+ Enabled = IsNextEnabled;
+ Image = NextRecord;
+ InFooterBar = true;
+ ToolTip = 'Move to next step.';
+
+ trigger OnAction()
+ begin
+ ConnectorImpl.CreateAccount(Rec, Password, Certificate, CertificatePassword, Account);
+ CurrPage.Close();
+ end;
+ }
+ }
+ }
+
+ var
+ Account: Record "File Account";
+ MediaResources: Record "Media Resources";
+ ConnectorImpl: Codeunit "Ext. SFTP Connector Impl";
+ [NonDebuggable]
+ Password, CertificatePassword, Certificate : Text;
+ CertificateStatusText: Text;
+ IsNextEnabled: Boolean;
+ TopBannerVisible: Boolean;
+ PasswordVisible, CertificateVisible : Boolean;
+
+ trigger OnOpenPage()
+ var
+ AssistedSetupLogoTok: Label 'ASSISTEDSETUP-NOTEXT-400PX.PNG', Locked = true;
+ begin
+ Rec.Init();
+ Rec.Insert();
+
+ if MediaResources.Get(AssistedSetupLogoTok) and (CurrentClientType() = ClientType::Web) then
+ TopBannerVisible := MediaResources."Media Reference".HasValue();
+
+ UpdateAuthTypeVisibility();
+ UpdateCertificateStatus();
+ end;
+
+ internal procedure GetAccount(var FileAccount: Record "File Account"): Boolean
+ begin
+ if IsNullGuid(Account."Account Id") then
+ exit(false);
+
+ FileAccount := Account;
+
+ exit(true);
+ end;
+
+ local procedure UpdateAuthTypeVisibility()
+ begin
+ PasswordVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Password;
+ CertificateVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Certificate;
+
+ if CertificateVisible then
+ UpdateCertificateStatus();
+ end;
+
+ local procedure UpdateCertificateStatus()
+ var
+ NoCertificateUploadedLbl: Label 'Click to upload certificate file...';
+ CertificateUploadedLbl: Label 'Certificate uploaded (click to change)';
+ begin
+ if Certificate = '' then
+ CertificateStatusText := NoCertificateUploadedLbl
+ else
+ CertificateStatusText := CertificateUploadedLbl;
+ end;
+}
\ No newline at end of file
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAuthType.Enum.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAuthType.Enum.al
new file mode 100644
index 0000000000..0c2aff7247
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAuthType.Enum.al
@@ -0,0 +1,31 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+///
+/// Specifies the authentication types for SFTP accounts.
+///
+enum 4621 "Ext. SFTP Auth Type"
+{
+ Extensible = false;
+ Access = Public;
+
+ ///
+ /// Authenticate using password.
+ ///
+ value(0; Password)
+ {
+ Caption = 'Password';
+ }
+
+ ///
+ /// Authenticate using private key.
+ ///
+ value(1; Certificate)
+ {
+ Caption = 'Certificate';
+ }
+}
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al
new file mode 100644
index 0000000000..3a2a7fa8a7
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al
@@ -0,0 +1,21 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+///
+/// Enum extension to register the SFTP connector.
+///
+enumextension 4621 "Ext. SFTP Connector" extends "Ext. File Storage Connector"
+{
+ ///
+ /// The SFTP connector.
+ ///
+ value(4621; "SFTP")
+ {
+ Caption = 'SFTP';
+ Implementation = "External File Storage Connector" = "Ext. SFTP Connector Impl";
+ }
+}
\ No newline at end of file
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al
new file mode 100644
index 0000000000..473feb955b
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al
@@ -0,0 +1,497 @@
+// ------------------------------------------------------------------------------------------------
+// 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.DataAdministration;
+using System.SFTPClient;
+using System.Text;
+using System.Utilities;
+
+codeunit 4621 "Ext. SFTP Connector Impl" implements "External File Storage Connector"
+{
+ Access = Internal;
+ InherentEntitlements = X;
+ InherentPermissions = X;
+ Permissions = tabledata "Ext. SFTP Account" = rimd;
+
+ var
+ ConnectorDescriptionTxt: Label 'Use SFTP Server to store and retrieve files.', MaxLength = 250;
+ NotRegisteredAccountErr: Label 'We could not find the account. Typically, this is because the account has been deleted.';
+ PathSeparatorTok: Label '/', Locked = true;
+
+ ///
+ /// Gets a List of Files stored on the provided account.
+ ///
+ /// The file account ID which is used to get the file.
+ /// The file path to list.
+ /// Defines the pagination data.
+ /// 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
+ FolderContent: Record "SFTP Folder Content";
+ SFTPClient: Codeunit "SFTP Client";
+ Response: Codeunit "SFTP Operation Response";
+ OrginalPath: Text;
+ begin
+ OrginalPath := Path;
+ InitPath(AccountId, Path);
+ InitSFTPClient(AccountId, SFTPClient);
+ Response := SFTPClient.ListFiles(Path, FolderContent);
+ SFTPClient.Disconnect();
+
+ if Response.IsError() then
+ ShowError(Response);
+
+ FilePaginationData.SetEndOfListing(true);
+
+ FolderContent.SetRange("Is Directory", false);
+ if not FolderContent.FindSet() then
+ exit;
+
+ repeat
+ TempFileAccountContent.Init();
+ TempFileAccountContent.Name := FolderContent.Name;
+ TempFileAccountContent.Type := TempFileAccountContent.Type::"File";
+ TempFileAccountContent."Parent Directory" := CopyStr(OrginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory"));
+ TempFileAccountContent.Insert();
+ until FolderContent.Next() = 0;
+ end;
+
+ ///
+ /// Gets a file from the provided account.
+ ///
+ /// The file account ID which is used to get the file.
+ /// The file path inside the file account.
+ /// The Stream were the file is read to.
+ procedure GetFile(AccountId: Guid; Path: Text; Stream: InStream)
+ var
+ SFTPClient: Codeunit "SFTP Client";
+ Response: Codeunit "SFTP Operation Response";
+ Content: HttpContent;
+ TempBlobStream: InStream;
+ begin
+ InitPath(AccountId, Path);
+ InitSFTPClient(AccountId, SFTPClient);
+
+ Response := SFTPClient.GetFileAsStream(Path, TempBlobStream);
+ SFTPClient.Disconnect();
+
+ if Response.IsError() then
+ ShowError(Response);
+
+ // Platform fix: For some reason the Stream from GetFileAsStream dies after leaving the interface
+ Content.WriteFrom(TempBlobStream);
+ Content.ReadAs(Stream);
+ end;
+
+ ///
+ /// Create a file in the provided account.
+ ///
+ /// The file account ID which is used to send out the file.
+ /// The file path inside the file account.
+ /// The Stream were the file is read from.
+ procedure CreateFile(AccountId: Guid; Path: Text; Stream: InStream)
+ var
+ SFTPClient: Codeunit "SFTP Client";
+ Response: Codeunit "SFTP Operation Response";
+ begin
+ InitPath(AccountId, Path);
+ InitSFTPClient(AccountId, SFTPClient);
+
+ Response := SFTPClient.PutFileStream(Path, Stream);
+ SFTPClient.Disconnect();
+
+ if Response.IsError() then
+ ShowError(Response);
+ end;
+
+ ///
+ /// Copies as file inside the provided account.
+ ///
+ /// The file account ID which is used to send out the file.
+ /// The source file path.
+ /// The target file path.
+ procedure CopyFile(AccountId: Guid; SourcePath: Text; TargetPath: Text)
+ var
+ TempBlob: Codeunit "Temp Blob";
+ Stream: InStream;
+ begin
+ TempBlob.CreateInStream(Stream);
+
+ GetFile(AccountId, SourcePath, Stream);
+ CreateFile(AccountId, TargetPath, Stream);
+ end;
+
+ ///
+ /// Move as file inside the provided account.
+ ///
+ /// The file account ID which is used to send out the file.
+ /// The source file path.
+ /// The target file path.
+ procedure MoveFile(AccountId: Guid; SourcePath: Text; TargetPath: Text)
+ var
+ SFTPClient: Codeunit "SFTP Client";
+ Response: Codeunit "SFTP Operation Response";
+ begin
+ InitSFTPClient(AccountId, SFTPClient);
+ InitPath(AccountId, SourcePath);
+ InitPath(AccountId, TargetPath);
+
+ Response := SFTPClient.MoveFile(SourcePath, TargetPath);
+ SFTPClient.Disconnect();
+
+ if Response.IsError() then
+ ShowError(Response);
+ end;
+
+ ///
+ /// Checks if a file exists on the provided account.
+ ///
+ /// The file account ID which is used to send out the file.
+ /// The file path inside the file account.
+ /// Returns true if the file exists
+ procedure FileExists(AccountId: Guid; Path: Text) Result: Boolean
+ var
+ SFTPClient: Codeunit "SFTP Client";
+ Response: Codeunit "SFTP Operation Response";
+ begin
+ InitPath(AccountId, Path);
+ InitSFTPClient(AccountId, SFTPClient);
+
+ Response := SFTPClient.FileExists(Path, Result);
+ SFTPClient.Disconnect();
+
+ if Response.IsError() then
+ ShowError(Response);
+ end;
+
+ ///
+ /// Deletes a file exists on the provided account.
+ ///
+ /// The file account ID which is used to send out the file.
+ /// The file path inside the file account.
+ procedure DeleteFile(AccountId: Guid; Path: Text)
+ var
+ SFTPClient: Codeunit "SFTP Client";
+ Response: Codeunit "SFTP Operation Response";
+ begin
+ InitPath(AccountId, Path);
+ InitSFTPClient(AccountId, SFTPClient);
+
+ Response := SFTPClient.DeleteFile(Path);
+ SFTPClient.Disconnect();
+
+ if Response.IsError() then
+ ShowError(Response);
+ end;
+
+ ///
+ /// Gets a List of Directories stored on the provided account.
+ ///
+ /// The file account ID which is used to get the file.
+ /// The file path to list.
+ /// Defines the pagination data.
+ /// 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
+ FolderContent: Record "SFTP Folder Content";
+ SFTPClient: Codeunit "SFTP Client";
+ Response: Codeunit "SFTP Operation Response";
+ OrginalPath: Text;
+ begin
+ OrginalPath := Path;
+ InitPath(AccountId, Path);
+ InitSFTPClient(AccountId, SFTPClient);
+ Response := SFTPClient.ListFiles(Path, FolderContent);
+ SFTPClient.Disconnect();
+
+ if Response.IsError() then
+ ShowError(Response);
+
+ FilePaginationData.SetEndOfListing(true);
+
+ FolderContent.SetRange("Is Directory", true);
+ FolderContent.SetFilter(Name, '<>%1&<>%2', '.', '..'); // Exclude . and ..
+ if not FolderContent.FindSet() then
+ exit;
+
+ repeat
+ TempFileAccountContent.Init();
+ TempFileAccountContent.Name := FolderContent.Name;
+ TempFileAccountContent.Type := TempFileAccountContent.Type::Directory;
+ TempFileAccountContent."Parent Directory" := CopyStr(OrginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory"));
+ TempFileAccountContent.Insert();
+ until FolderContent.Next() = 0;
+ end;
+
+ ///
+ /// Creates a directory on the provided account.
+ ///
+ /// The file account ID which is used to send out the file.
+ /// The directory path inside the file account.
+ procedure CreateDirectory(AccountId: Guid; Path: Text)
+ var
+ SFTPClient: Codeunit "SFTP Client";
+ Response: Codeunit "SFTP Operation Response";
+ begin
+ InitPath(AccountId, Path);
+ InitSFTPClient(AccountId, SFTPClient);
+ Response := SFTPClient.CreateDirectory(Path);
+ SFTPClient.Disconnect();
+
+ if Response.IsError() then
+ ShowError(Response);
+ end;
+
+ ///
+ /// Checks if a directory exists on the provided account.
+ ///
+ /// The file account ID which is used to send out the file.
+ /// The directory path inside the file account.
+ /// Returns true if the directory exists
+ procedure DirectoryExists(AccountId: Guid; Path: Text) Result: Boolean
+ begin
+ exit(FileExists(AccountId, Path));
+ end;
+
+ ///
+ /// Deletes a directory exists on the provided account.
+ ///
+ /// The file account ID which is used to send out the file.
+ /// The directory path inside the file account.
+ procedure DeleteDirectory(AccountId: Guid; Path: Text)
+ begin
+ DeleteFile(AccountId, Path);
+ end;
+
+ ///
+ /// Gets the registered accounts for the SFTP connector.
+ ///
+ /// Out parameter holding all the registered accounts for the SFTP connector.
+ procedure GetAccounts(var TempAccounts: Record "File Account" temporary)
+ var
+ Account: Record "Ext. SFTP Account";
+ begin
+ if not Account.FindSet() then
+ exit;
+
+ repeat
+ TempAccounts."Account Id" := Account.Id;
+ TempAccounts.Name := Account.Name;
+ TempAccounts.Connector := Enum::"Ext. File Storage Connector"::"SFTP";
+ TempAccounts.Insert();
+ until Account.Next() = 0;
+ end;
+
+ ///
+ /// Shows accounts information.
+ ///
+ /// The ID of the account to show.
+ procedure ShowAccountInformation(AccountId: Guid)
+ var
+ AccountLocal: Record "Ext. SFTP Account";
+ begin
+ if not AccountLocal.Get(AccountId) then
+ Error(NotRegisteredAccountErr);
+
+ AccountLocal.SetRecFilter();
+ Page.Run(Page::"Ext. SFTP Account", AccountLocal);
+ end;
+
+ ///
+ /// Register an file account for the SFTP connector.
+ ///
+ /// Out parameter holding details of the registered account.
+ /// True if the registration was successful; false - otherwise.
+ procedure RegisterAccount(var TempAccount: Record "File Account" temporary): Boolean
+ var
+ AccountWizard: Page "Ext. SFTP Account Wizard";
+ begin
+ AccountWizard.RunModal();
+
+ exit(AccountWizard.GetAccount(TempAccount));
+ end;
+
+ ///
+ /// Deletes an file account for the SFTP connector.
+ ///
+ /// The ID of the SFTP account
+ /// True if an account was deleted.
+ procedure DeleteAccount(AccountId: Guid): Boolean
+ var
+ AccountLocal: Record "Ext. SFTP Account";
+ begin
+ if AccountLocal.Get(AccountId) then
+ exit(AccountLocal.Delete());
+
+ exit(false);
+ end;
+
+ ///
+ /// Gets a description of the SFTP connector.
+ ///
+ /// A short description of the SFTP connector.
+ procedure GetDescription(): Text[250]
+ begin
+ exit(ConnectorDescriptionTxt);
+ end;
+
+ ///
+ /// Gets the SFTP connector logo.
+ ///
+ /// A base64-formatted image to be used as logo.
+ procedure GetLogoAsBase64(): Text
+ var
+ Base64Convert: Codeunit "Base64 Convert";
+ Stream: InStream;
+ begin
+ NavApp.GetResource('connector-logo.png', Stream);
+ exit(Base64Convert.ToBase64(Stream));
+ end;
+
+ internal procedure IsAccountValid(var TempAccount: Record "Ext. SFTP Account" temporary): Boolean
+ begin
+ if TempAccount.Name = '' then
+ exit(false);
+
+ if TempAccount."Hostname" = '' then
+ exit(false);
+
+ if TempAccount."Base Relative Folder Path" = '' then
+ exit(false);
+
+ if TempAccount.Username = '' then
+ exit(false);
+
+ if TempAccount.Port = 0 then
+ exit(false);
+
+ exit(true);
+ end;
+
+ internal procedure CreateAccount(var AccountToCopy: Record "Ext. SFTP Account"; Password: SecretText; Certificate: Text; CertificatePassword: SecretText; var TempFileAccount: Record "File Account" temporary)
+ var
+ NewAccount: Record "Ext. SFTP Account";
+ begin
+ NewAccount.TransferFields(AccountToCopy);
+ NewAccount.Id := CreateGuid();
+
+ case NewAccount."Authentication Type" of
+ Enum::"Ext. SFTP Auth Type"::Password:
+ NewAccount.SetPassword(Password);
+ Enum::"Ext. SFTP Auth Type"::Certificate:
+ begin
+ NewAccount.SetCertificate(Certificate);
+ NewAccount.SetCertificatePassword(CertificatePassword);
+ end;
+ end;
+
+ NewAccount.Insert();
+
+ TempFileAccount."Account Id" := NewAccount.Id;
+ TempFileAccount.Name := NewAccount.Name;
+ TempFileAccount.Connector := Enum::"Ext. File Storage Connector"::"SFTP";
+ end;
+
+ [NonDebuggable]
+ local procedure InitSFTPClient(var AccountId: Guid; var SFTPClient: Codeunit "SFTP Client")
+ var
+ SFTPAccount: Record "Ext. SFTP Account";
+ Response: Codeunit "SFTP Operation Response";
+ Stream: InStream;
+ AccountDisabledErr: Label 'The account "%1" is disabled.', Comment = '%1 - Account Name';
+ begin
+ SFTPAccount.Get(AccountId);
+ if SFTPAccount.Disabled then
+ Error(AccountDisabledErr, SFTPAccount.Name);
+
+ AddFingerprints(SFTPAccount."Fingerprints", SFTPClient);
+
+ case SFTPAccount."Authentication Type" of
+ Enum::"Ext. SFTP Auth Type"::Password:
+ Response := SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, SFTPAccount.GetPassword(SFTPAccount."Password Key"));
+ Enum::"Ext. SFTP Auth Type"::Certificate:
+ begin
+ SFTPAccount.GetCertificate(SFTPAccount."Certificate Key").CreateInStream(Stream);
+ if IsNullGuid(SFTPAccount."Certificate Password Key") then
+ Response := SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, Stream)
+ else
+ Response := SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, Stream, SFTPAccount.GetCertificatePassword(SFTPAccount."Certificate Password Key"));
+ end;
+ end;
+
+ if Response.IsError() then
+ ShowError(Response);
+ end;
+
+ local procedure ShowError(var Response: Codeunit "SFTP Operation Response")
+ var
+ ErrorOccurredErr: Label 'An error occurred.\%1', Comment = '%1 - Error message from SFTP Server';
+ begin
+ Error(ErrorOccurredErr, Response.GetError());
+ end;
+
+ local procedure InitPath(AccountId: Guid; var Path: Text)
+ var
+ SFTPAccount: Record "Ext. SFTP Account";
+ begin
+ SFTPAccount.Get(AccountId);
+ Path := CombinePath(SFTPAccount."Base Relative Folder Path", Path);
+ end;
+
+ local procedure CombinePath(Parent: Text; Child: Text): Text
+ var
+ JoinPathTok: Label '%1/%2', Locked = true;
+ begin
+ if Parent = '' then
+ exit(Child);
+
+ if Child = '' then
+ exit(Parent);
+
+ exit(StrSubstNo(JoinPathTok, Parent.TrimEnd(PathSeparatorTok), Child.TrimStart(PathSeparatorTok)));
+ end;
+
+ local procedure AddFingerprints(Fingerprints: Text; var SFTPClient: Codeunit "SFTP Client")
+ var
+ Fingerprint: Text;
+ begin
+ foreach Fingerprint in Fingerprints.Split(',') do
+ AddFingerprint(Fingerprint, SFTPClient);
+ end;
+
+ local procedure AddFingerprint(Fingerprint: Text; var SFTPClient: Codeunit "SFTP Client")
+ var
+ SHA256PrefixTok: Label 'sha256:', Locked = true;
+ MD5PrefixTok: Label 'md5:', Locked = true;
+ InvalidFingerprintErr: Label 'Fingerprint must start with "md5:" or "sha256:".';
+ begin
+ Fingerprint := Fingerprint.Trim();
+ if Fingerprint = '' then
+ exit;
+
+ case true of
+ Fingerprint.StartsWith(SHA256PrefixTok):
+ SFTPClient.AddFingerprintSHA256(Fingerprint.Substring(StrLen(SHA256PrefixTok) + 1));
+ Fingerprint.StartsWith(MD5PrefixTok):
+ SFTPClient.AddFingerprintMD5(Fingerprint.Substring(StrLen(MD5PrefixTok) + 1));
+ else
+ Error(InvalidFingerprintErr);
+ end;
+ 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
+ Account: Record "Ext. SFTP Account";
+ begin
+ Account.SetRange(Disabled, false);
+ if Account.IsEmpty() then
+ exit;
+
+ Account.ModifyAll(Disabled, true);
+ end;
+}
\ No newline at end of file
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/ExtensionLogo.png b/src/Apps/W1/External File Storage - SFTP Connector/Test/ExtensionLogo.png
new file mode 100644
index 0000000000..30941b354f
Binary files /dev/null and b/src/Apps/W1/External File Storage - SFTP Connector/Test/ExtensionLogo.png differ
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/README.md b/src/Apps/W1/External File Storage - SFTP Connector/Test/README.md
new file mode 100644
index 0000000000..8dde636030
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/Test/README.md
@@ -0,0 +1,56 @@
+# External File Storage - SFTP Connector Tests
+
+This extension contains automated tests for the External File Storage - SFTP Connector app.
+
+## Overview
+
+The test suite validates the functionality of the SFTP connector implementation for Business Central's External File Storage framework. It ensures that SFTP accounts can be properly registered, managed, and operated within the system.
+
+## Test Coverage
+
+### Account Management Tests
+
+- **TestMultipleAccountsCanBeRegistered**: Verifies that multiple SFTP accounts can be registered and retrieved correctly. Tests the ability to create up to 3 accounts and validate their persistence through the `GetAccounts` method.
+
+- **TestShowAccountInformation**: Validates that account information is correctly displayed in the account page, ensuring all fields (name, hostname, username, port, fingerprints, and base folder path) are properly rendered.
+
+### Environment Management Tests
+
+- **TestEnviromentCleanupDisablesAccounts**: Ensures that when an environment is copied, all SFTP accounts are automatically disabled as a security measure. This test verifies the `OnAfterCopyEnvironmentPerCompany` trigger functionality.
+
+## Test Structure
+
+### Source Files
+
+- **ExtSFTPConnectorTest.Codeunit.al**: Main test codeunit containing all test scenarios
+- **mocks/ExtSFTPAccountMock.Codeunit.al**: Mock implementation for test data generation
+
+### Test Helpers
+
+The test suite includes several helper procedures:
+
+- `Initialize()`: Cleans up test data before each test
+- `SetBasicAccount()`: Generates randomized test account data using the Any library
+- `AccountRegisterPageHandler()`: Modal page handler for account registration
+- `AccountShowPageHandler()`: Page handler for account information verification
+
+## Dependencies
+
+- **External File Storage - SFTP Connector**: The main application being tested
+- **Library Assert**: Assertion library for test validation
+- **Any**: Random test data generation library
+- **System Application Test Library**: System-level test utilities
+
+## Running the Tests
+
+These tests are designed to run in a Business Central test environment with the following attributes:
+
+- Subtype: Test
+- TestPermissions: Disabled
+- TransactionModel: AutoRollback (for most tests)
+
+## Notes
+
+- All tests use randomized data to ensure independence and repeatability
+- Tests follow the Given-When-Then pattern for clarity
+- Transaction rollback ensures tests don't affect the database state
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json b/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json
new file mode 100644
index 0000000000..0d3b3a3311
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json
@@ -0,0 +1,57 @@
+{
+ "id": "87c3fa98-904d-452d-95fe-5de2c7f0b624",
+ "name": "External File Storage - SFTP Connector Tests",
+ "publisher": "Microsoft",
+ "brief": "Tests for the External File Storage - SFTP Connector app",
+ "description": "Tests for the External File Storage - SFTP Connector app",
+ "version": "28.0.0.0",
+ "privacyStatement": "https://go.microsoft.com/fwlink/?linkid=724009",
+ "EULA": "https://go.microsoft.com/fwlink/?linkid=2009120",
+ "help": "https://go.microsoft.com/fwlink/?linkid=2134520",
+ "url": "https://go.microsoft.com/fwlink/?linkid=724011",
+ "logo": "ExtensionLogo.png",
+ "application": "28.0.0.0",
+ "dependencies": [
+ {
+ "id": "e0df20ef-75a2-4fae-8e3a-88140ab29507",
+ "name": "External File Storage - SFTP Connector",
+ "publisher": "Microsoft",
+ "version": "28.0.0.0"
+ },
+ {
+ "id": "dd0be2ea-f733-4d65-bb34-a28f4624fb14",
+ "name": "Library Assert",
+ "publisher": "Microsoft",
+ "version": "28.0.0.0"
+ },
+ {
+ "id": "e7320ebb-08b3-4406-b1ec-b4927d3e280b",
+ "name": "Any",
+ "publisher": "Microsoft",
+ "version": "28.0.0.0"
+ },
+ {
+ "id": "9856ae4f-d1a7-46ef-89bb-6ef056398228",
+ "name": "System Application Test Library",
+ "publisher": "Microsoft",
+ "version": "28.0.0.0"
+ }
+ ],
+ "screenshots": [],
+ "platform": "28.0.0.0",
+ "idRanges": [
+ {
+ "from": 144590,
+ "to": 144599
+ }
+ ],
+ "resourceExposurePolicy": {
+ "allowDebugging": true,
+ "allowDownloadingSource": true,
+ "includeSourceInSymbolFile": true
+ },
+ "contextSensitiveHelpUrl": "https://go.microsoft.com/fwlink/?linkid=2134520",
+ "features": [
+ "TranslationFile"
+ ]
+}
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/src/ExtSFTPConnectorTest.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/Test/src/ExtSFTPConnectorTest.Codeunit.al
new file mode 100644
index 0000000000..7241327e0f
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/Test/src/ExtSFTPConnectorTest.Codeunit.al
@@ -0,0 +1,162 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace System.Test.ExternalFileStorage;
+
+using System.Environment;
+using System.ExternalFileStorage;
+using System.TestLibraries.Utilities;
+
+codeunit 144591 "Ext. SFTP Connector Test"
+{
+ Subtype = Test;
+ TestPermissions = Disabled;
+
+ [Test]
+ [Scope('OnPrem')]
+ [HandlerFunctions('AccountRegisterPageHandler')]
+ [TransactionModel(TransactionModel::AutoRollback)]
+ procedure TestMultipleAccountsCanBeRegistered()
+ var
+ FileAccount: Record "File Account";
+ ExtFileConnector: Codeunit "Ext. SFTP Connector Impl";
+ FileAccounts: TestPage "File Accounts";
+ AccountIds: array[3] of Guid;
+ AccountName: array[3] of Text[250];
+ Index: Integer;
+ begin
+ // [Scenario] Create multiple accounts
+ Initialize();
+
+ // [When] Multiple accounts are registered
+ for Index := 1 to 3 do begin
+ SetBasicAccount();
+
+ Assert.IsTrue(ExtFileConnector.RegisterAccount(FileAccount), 'Failed to register account.');
+ AccountIds[Index] := FileAccount."Account Id";
+ AccountName[Index] := FileAccountMock.Name();
+
+ // [Then] Accounts are retrieved from the GetAccounts method
+ FileAccount.DeleteAll();
+ ExtFileConnector.GetAccounts(FileAccount);
+ Assert.RecordCount(FileAccount, Index);
+ end;
+
+ FileAccounts.OpenView();
+ for Index := 1 to 3 do begin
+ FileAccounts.GoToKey(AccountIds[Index], Enum::"Ext. File Storage Connector"::SFTP);
+ Assert.AreEqual(AccountName[Index], FileAccounts.NameField.Value(), 'A different name was expected.');
+ end;
+ end;
+
+ [Test]
+ [Scope('OnPrem')]
+ [HandlerFunctions('AccountRegisterPageHandler')]
+ [TransactionModel(TransactionModel::AutoRollback)]
+ procedure TestEnvironmentCleanupDisablesAccounts()
+ var
+ FileAccount: Record "File Account";
+ ExtSFTPAccount: Record "Ext. SFTP Account";
+ ExtFileConnector: Codeunit "Ext. SFTP Connector Impl";
+ EnvironmentTriggers: Codeunit "Environment Triggers";
+ AccountIds: array[3] of Guid;
+ Index: Integer;
+ begin
+ // [Scenario] Create multiple accounts
+ Initialize();
+
+ // [When] Multiple accounts are registered
+ for Index := 1 to 3 do begin
+ SetBasicAccount();
+
+ Assert.IsTrue(ExtFileConnector.RegisterAccount(FileAccount), 'Failed to register account.');
+ AccountIds[Index] := FileAccount."Account Id";
+
+ // [Then] Accounts are retrieved from the GetAccounts method
+ FileAccount.DeleteAll();
+ ExtFileConnector.GetAccounts(FileAccount);
+ Assert.RecordCount(FileAccount, Index);
+ end;
+
+ ExtSFTPAccount.SetRange(Disabled, true);
+ Assert.IsTrue(ExtSFTPAccount.IsEmpty(), 'Accounts are already disabled.');
+
+ EnvironmentTriggers.OnAfterCopyEnvironmentPerCompany(0, Any.AlphabeticText(30), 1, Any.AlphabeticText(30));
+
+ Assert.IsFalse(ExtSFTPAccount.IsEmpty(), 'Accounts are not disabled.');
+ end;
+
+ [Test]
+ [Scope('OnPrem')]
+ [HandlerFunctions('AccountRegisterPageHandler,AccountShowPageHandler')]
+ [TransactionModel(TransactionModel::AutoRollback)]
+ procedure TestShowAccountInformation()
+ var
+ FileAccount: Record "File Account";
+ FileConnector: Codeunit "Ext. SFTP Connector Impl";
+ begin
+ // [Scenario] Account Information is displayed in the Account page.
+
+ // [Given] An file account
+ Initialize();
+ SetBasicAccount();
+ FileConnector.RegisterAccount(FileAccount);
+
+ // [When] The ShowAccountInformation method is invoked
+ FileConnector.ShowAccountInformation(FileAccount."Account Id");
+
+ // [Then] The account page opens and displays the information
+ // Verify in AccountModalPageHandler
+ end;
+
+ local procedure Initialize()
+ var
+ ExtSFTPAccount: Record "Ext. SFTP Account";
+ begin
+ ExtSFTPAccount.DeleteAll();
+ end;
+
+ local procedure SetBasicAccount()
+ begin
+ FileAccountMock.Name(CopyStr(Any.AlphanumericText(250), 1, 250));
+ FileAccountMock.Hostname(CopyStr(Any.AlphanumericText(250), 1, 250));
+ FileAccountMock.Username(CopyStr(Any.AlphanumericText(256), 1, 256));
+ FileAccountMock.BaseRelativeFolderPath(CopyStr(Any.AlphanumericText(250), 1, 250));
+ FileAccountMock.Port(Any.IntegerInRange(1, 65535));
+ FileAccountMock.Fingerprints(CopyStr(Any.AlphanumericText(1024), 1, 1024));
+ FileAccountMock.Password('testpassword');
+ end;
+
+ [ModalPageHandler]
+ procedure AccountRegisterPageHandler(var AccountWizard: TestPage "Ext. SFTP Account Wizard")
+ begin
+ // Setup account
+ AccountWizard.NameField.SetValue(FileAccountMock.Name());
+ AccountWizard.Hostname.SetValue(FileAccountMock.Hostname());
+ AccountWizard.Username.SetValue(FileAccountMock.Username());
+ AccountWizard."Base Relative Folder Path".SetValue(FileAccountMock.BaseRelativeFolderPath());
+ AccountWizard.Password.SetValue(FileAccountMock.Password());
+ AccountWizard.Port.SetValue(FileAccountMock.Port());
+ AccountWizard.Fingerprints.SetValue(FileAccountMock.Fingerprints());
+ AccountWizard.Next.Invoke();
+ end;
+
+ [PageHandler]
+ procedure AccountShowPageHandler(var Account: TestPage "Ext. SFTP Account")
+ begin
+ // Verify the account
+ Assert.AreEqual(FileAccountMock.Name(), Account.NameField.Value(), 'A different name was expected.');
+ Assert.AreEqual(FileAccountMock.Hostname(), Account.Hostname.Value(), 'A different hostname was expected.');
+ Assert.AreEqual(FileAccountMock.Username(), Account.Username.Value(), 'A different username was expected.');
+ Assert.AreEqual(Format(FileAccountMock.Port()), Account.Port.Value(), 'A different port was expected.');
+ Assert.AreEqual(FileAccountMock.Fingerprints(), Account.Fingerprints.Value(), 'A different fingerprints was expected.');
+ Assert.AreEqual(FileAccountMock.BaseRelativeFolderPath(), Account."Base Relative Folder Path".Value(), 'A different base relative folder path was expected.');
+ end;
+
+ var
+ Any: Codeunit Any;
+ Assert: Codeunit "Library Assert";
+ FileAccountMock: Codeunit "Ext. SFTP Account Mock";
+}
\ No newline at end of file
diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/src/mocks/ExtSFTPAccountMock.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/Test/src/mocks/ExtSFTPAccountMock.Codeunit.al
new file mode 100644
index 0000000000..f47e26ab56
--- /dev/null
+++ b/src/Apps/W1/External File Storage - SFTP Connector/Test/src/mocks/ExtSFTPAccountMock.Codeunit.al
@@ -0,0 +1,91 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+codeunit 144590 "Ext. SFTP Account Mock"
+{
+ Access = Internal;
+ SingleInstance = true;
+
+ procedure Name(): Text[250]
+ begin
+ exit(AccName);
+ end;
+
+ procedure Name(Value: Text[250])
+ begin
+ AccName := Value;
+ end;
+
+ procedure Hostname(): Text[250]
+ begin
+ exit(AccHostname);
+ end;
+
+ procedure Hostname(Value: Text[250])
+ begin
+ AccHostname := Value;
+ end;
+
+ procedure Username(): Text[256]
+ begin
+ exit(AccUsername);
+ end;
+
+ procedure Username(Value: Text[256])
+ begin
+ AccUsername := Value;
+ end;
+
+ procedure Fingerprints(): Text[1024]
+ begin
+ exit(AccFingerprints);
+ end;
+
+ procedure Fingerprints(Value: Text[1024])
+ begin
+ AccFingerprints := Value;
+ end;
+
+ procedure Port(): Integer
+ begin
+ exit(AccPort);
+ end;
+
+ procedure Port(Value: Integer)
+ begin
+ AccPort := Value;
+ end;
+
+ procedure BaseRelativeFolderPath(): Text[250]
+ begin
+ exit(AccBaseRelativeFolderPath);
+ end;
+
+ procedure BaseRelativeFolderPath(Value: Text[250])
+ begin
+ AccBaseRelativeFolderPath := Value;
+ end;
+
+ procedure Password(): Text
+ begin
+ exit(AccPassword);
+ end;
+
+ procedure Password(Value: Text)
+ begin
+ AccPassword := Value;
+ end;
+
+ var
+ AccName: Text[250];
+ AccHostname: Text[250];
+ AccUsername: Text[256];
+ AccFingerprints: Text[1024];
+ AccBaseRelativeFolderPath: Text[250];
+ AccPassword: Text;
+ AccPort: Integer;
+}
\ No newline at end of file