A new feature has been added to Business Central 28, the ability to handle SFTP servers natively in Business Central AL code. This is a huge new feature for those of us that need to communicate with external systems via SFTP. Asynchronous integrations via FTP are still very common, which makes this a very powerful feature.

This is all made possible through a new code unit call “SFTP Client”, which has the id of 9762.

We need a place to test; here is a list of Free Public SFTP Servers – SFTP.net

I’m going to use FileZilla to check if the server is running and capture the fingerprint. That will be critical later.

We need to store FTP access information somewhere safe. For this example, I’m going to use Isolated Storage.

Here is a refresher on handling secure data in Business Central.

ARDSFTPServerSettings.CodeUnit.AL

namespace AardvarkLabs.FileParsingExamples;

codeunit 50000 ARD_SFTPServerSettings
{
    Access = Internal;

    var
        FTPServerUserNameLbl: Label 'FTPServerUserNAme';
        FTPServerPasswordLbl: Label 'FTPServerPassword';
        FTPServerHostLbl: Label 'FTPServerHost';
        FTPServerPortLbl: Label 'FTPServerPort';
        FTPServerFingerPrintLbl: Label 'FTPServerFingerPrint';

    procedure GetServerUserName():Text
    var
        value: Text;
    begin
        IsolatedStorage.get(FTPServerUserNameLbl, value);
        exit(value);
    end;    

    procedure SetServerUserName(value: Text)
    begin
        IsolatedStorage.set(FTPServerUserNameLbl, value);
    end;

    procedure GetServerPassword():SecretText
    var
        value: SecretText;
    begin
        if NOT IsolatedStorage.get(FTPServerPasswordLbl, value) then value := SecretStrSubstNo('');
        exit(value);
    end;

    procedure SetServerPassword(value: SecretText)
    begin
        IsolatedStorage.set(FTPServerPasswordLbl, value);
    end;

    procedure GetServerHost():Text
    var
        value: Text;
    begin
        IsolatedStorage.get(FTPServerHostLbl, value);
        exit(value);
    end;

    procedure SetServerHost(value: Text)
    begin
        IsolatedStorage.set(FTPServerHostLbl, value);
    end;    

    procedure GetServerPort():Integer
    var
        value: Integer;
        TextValue: Text;
    begin
        IsolatedStorage.get(FTPServerPortLbl, TextValue);
        if TextValue = '' then
            value := 0
        else
            Evaluate(Value, TextValue);
        exit(value);
    end;

    procedure SetServerPort(value: Integer)
    var
        TextValue: Text;
    begin
        TextValue := Format(value);
        IsolatedStorage.set(FTPServerPortLbl, TextValue);
    end;

    procedure GetServerFingerPrint():text
    var
        value: text;
    begin
        if NOT IsolatedStorage.get(FTPServerFingerPrintLbl, value) then value := '';
        exit(value);
    end;

    procedure SetServerFingerPrint(value: text)
    begin
        IsolatedStorage.set(FTPServerFingerPrintLbl, value);
    end;
}

Here we see the common pattern of storing critical information in the Isolated Storage system.

Now we need a page for the settings. I created ARDFTPHandler.Page.al to hold and manage the settings.

namespace AardvarkLabs.FileParsingExamples;

using System.Utilities;

page 50012 ARD_FTPHandler
{
    ApplicationArea = All;
    Caption = 'FTP Handler';
    PageType = Card;
    SourceTable = "Integer";
    DataCaptionExpression = 'FTP Server Settings';
    UsageCategory = Administration;

    layout
    {
        area(Content)
        {
            group(General)
            {
                Caption = 'Server Settings';
                field(ServerHost; ServerHost)
                {
                    Caption = 'Server Host';
                    ToolTip = 'The hostname or IP address of the FTP server.';
                    ApplicationArea = All;

                    trigger onvalidatE()
                    begin
                        ServerSettings.SetServerHost(ServerHost);
                    end;
                }
                field(ServerPort; ServerPort)
                {
                    Caption = 'Server Port';
                    ToolTip = 'The port number of the FTP server.';
                    ApplicationArea = All;
                    trigger OnValidate()
                    begin
                        ServerSettings.SetServerPort(ServerPort);
                    end;
                }
                field(ServerFingerPrint; ServerFingerPrint)
                {
                    ApplicationArea = All;
                    Caption = 'Server FingerPrint';
                    ToolTip = 'The SSH Fingerprint of the FTP server.';
                    MaskType = Concealed;
                    trigger OnValidate()
                    begin
                        ServerSettings.SetServerFingerPrint(ServerFingerPrint);
                    end;
                }
                field(UserName; UserName)
                {
                    ApplicationArea = All;
                    Caption = 'Server User Name';
                    ToolTip = 'The username for the FTP server.';
                    trigger OnValidate()
                    begin
                        ServerSettings.SetServerUserName(UserName);
                    end;

                }
                field(ServerPassword; ServerPassword)
                {
                    ApplicationArea = All;
                    Caption = 'Server Password';
                    ToolTip = 'The password for the FTP server.';
                    MaskType = Concealed;
                    trigger OnValidate()
                    begin
                        ServerSettings.SetServerPassword(ServerPassword);
                    end;
                }
            }
            group(Files)
            {
                part(FileList; ARD_FTPFileList)
                {
                    ApplicationArea = All;
                }
            }
        }
    }

    var
        ServerSettings: Codeunit ARD_SFTPServerSettings;
        UserName: Text;
        ServerHost: Text;
        ServerPort: Integer;
        ServerFingerPrint: text;
        ServerPassword: text;

    trigger OnOpenPage()
    begin
        //Load the current server settings from the Isolated Storage when opening the page.
        ServerHost := ServerSettings.GetServerHost();
        ServerPort := ServerSettings.GetServerPort();
        ServerFingerPrint := ServerSettings.GetServerFingerPrint();
        UserName := ServerSettings.GetServerUserName();
    end;
}

This page handles the loading and saving of the FTP Settings. Note that the server fingerprint and password has the MaskType value set to Concealed to hide the password from people peeping. It would be better if this was a Secret Text, but the FTP CodeUnit doesn’t allow for Secret Text.

There is a Part in the page, which is where all the FTP handling actually happens.

The file list part is ARDFTPFileList.Page.al

namespace AardvarkLabs.FileParsingExamples;

using System.SFTPClient;

page 50013 ARD_FTPFileList
{
    ApplicationArea = All;
    Caption = 'FTP File List';
    PageType = ListPart;
    SourceTable = "SFTP Folder Content";
    InsertAllowed = true;

    layout
    {
        area(Content)
        {
            repeater(General)
            {
                field(Name; Rec.Name)
                {
                }
                field("Full Name"; Rec."Full Name")
                {
                    trigger OnDrillDown()
                    begin
                        if Rec."Is Directory" then begin
                            case Rec.Name of
                                '.': //return to the root
                                    currentPath := '';
                                '..': //go up one level
                                    currentPath := CopyStr(currentPath, 1, currentPath.lastindexof('/') - 1);
                                else //go down one level
                                    currentPath := Rec."Full Name";

                            end;
                            
                            //List the files at the new level
                            ListFTPFiles();
                        end else
                            DownloadFTPFile(Rec."Full Name"); //Download the file that was clicked on
                    end;
                }
                field(Length; Rec.Length)
                {
                }
                field("Is Directory"; Rec."Is Directory")
                {
                }
                field("Last Write Time"; Rec."Last Write Time")
                {
                }
            }
            
            group(Upload)
            {
                Caption = 'Upload File';
                field(UploadFileName; UploadFileName)
                {
                    ApplicationArea = All;
                    Caption = 'File Name';
                    ToolTip = 'The name of the file to be uploaded to the FTP server.';

                    trigger OnValidate()
                    begin
                        UploadFTPFile(UploadFileName);
                    end;
                }
            }
        }
    }
    actions
    {
        area(Processing)
        {
            action(ListFiles)
            {
                ApplicationArea = All;
                Caption = 'List Files';
                ToolTip = 'Lists the files in the root directory of the FTP server.';
                image = View;
                trigger OnAction()
                begin
                    ListFTPFiles();
                end;
            }
            action(DeleteFile)
            {
                ApplicationArea = All;
                Caption = 'Delete File';
                ToolTip = 'Deletes a file called data.txt from the root directory of the FTP server.';
                image = Delete;
                Scope = Repeater;

                trigger OnAction()
                begin
                    if (NOT Rec."Is Directory") and (Rec."Full Name" <> '') then
                        if Confirm('Are you sure you want to delete ' + Rec.Name + '?', false) then
                            DeleteFTPFile(Rec."Full Name");
                end;
            }
        }
    }

    var
        ServerSettings: Codeunit ARD_SFTPServerSettings;
        currentPath: Text;
        UploadFileName: Text;

    trigger OnOpenPage()
    begin
        currentPath := '';
    end;

    procedure ListFTPFiles()
    var
        TempFileList: Record "SFTP Folder Content" temporary;
        SFTPClient: Codeunit "SFTP Client";
    begin
        Rec.DeleteAll();

        //Setting up the client from the values in the Isolated Storage.
        SFTPClient.AddFingerPrintSHA256(ServerSettings.GetServerFingerPrint());
        SFTPClient.Initialize(ServerSettings.GetServerHost(), ServerSettings.GetServerPort(), ServerSettings.GetServerUserName(), ServerSettings.GetServerPassword());

        //Disconnect when complete or if there is an error.
        SFTPClient.ListFiles(currentPath, TempFileList);

        //Insert the files from the temporary record into the page's record to display them in the list.
        if TempFileList.FindSet() then
            repeat
                Rec := TempFileList;
                Rec.Insert();
            until TempFileList.Next() = 0
        else
            Message('No files found in the root directory of the FTP server.');

        CurrPage.Update(false);

        //Disconnect when complete or if there is an error.
        SFTPClient.Disconnect();
    end;

    procedure UploadFTPFile(FileName: Text)
    var
        SFTPClient: Codeunit "SFTP Client";
        InStream: InStream;
    begin
        if UploadIntoStream('', InStream) then begin
            //Setting up the client from the values in the Isolated Storage.
            SFTPClient.AddFingerPrintSHA256(ServerSettings.GetServerFingerPrint());
            SFTPClient.Initialize(ServerSettings.GetServerHost(), ServerSettings.GetServerPort(), ServerSettings.GetServerUserName(), ServerSettings.GetServerPassword());

            //Upload the file to the FTP server using the provided filename and filestream.
            SFTPClient.PutFileStream(FileName, InStream);
            //Disconnect when complete or if there is an error.
            SFTPClient.Disconnect();
        end else
            Message('File upload cancelled.');
    end;

    procedure DownloadFTPFile(FullFileName: Text)
    var
        SFTPClient: Codeunit "SFTP Client";
        InStream: InStream;
        FileContent: TextBuilder;
        CurrentText: Text;
    begin
        //Setting up the client from the values in the Isolated Storage.
        SFTPClient.AddFingerPrintSHA256(ServerSettings.GetServerFingerPrint());
        SFTPClient.Initialize(ServerSettings.GetServerHost(), ServerSettings.GetServerPort(), ServerSettings.GetServerUserName(), ServerSettings.GetServerPassword());

        //Download the file from the FTP server into a filestream.
        SFTPClient.GetFileAsStream(FullFileName, InStream);
        //Disconnect when complete or if there is an error.
        SFTPClient.Disconnect();

        while InStream.EOS = false do
            // Append each line of text to the TextBuilder
            if InStream.ReadText(CurrentText) <> 0 then
                FileContent.AppendLine(CurrentText);

        Dialog.Message(FileContent.ToText());
    end;

    procedure DeleteFTPFile(FullFileName: Text)
    var
        SFTPClient: Codeunit "SFTP Client";
    begin
        //Setting up the client from the values in the Isolated Storage.
        SFTPClient.AddFingerPrintSHA256(ServerSettings.GetServerFingerPrint());
        SFTPClient.Initialize(ServerSettings.GetServerHost(), ServerSettings.GetServerPort(), ServerSettings.GetServerUserName(), ServerSettings.GetServerPassword());

        //Delete the file from the FTP server using the provided filename.
        SFTPClient.DeleteFile(FullFileName);
        //Disconnect when complete or if there is an error.
        SFTPClient.Disconnect();
    end;
}

The FTP system only supports 4 commands. List Files, Upload File, Download File, and Delete File. Everything else has to be done as a series of actions based on these commands.

Each action follows the same pattern: Add the fingerprint to the SFTP Client, Initialize the client with Host URL, Port, Username, and Password. You then transact against the SFTP Server. Last step is to Disconnect.

Here it is in action:

I’ve filled out the test FTP server settings. I can use the menu in the FTP File List part to list out the files.

I can now navigate around the list part and work with the files in the server.

This simple example is all wired up to chat with your favorite FTP Server. Go ahead and give it a try, let me know if you have any questions in the comments section.

Microsoft provided example and further details here: BCApps/src/System Application/App/SFTP Client at main · microsoft/BCApps

As always, source code in AardvarkLabs GitHub.

Leave a comment

Trending