Codeunit 2501 Extension Marketplace, source in 25

Source242526272829metadata in 25

src/System Application/App/Extension Management/src/ExtensionMarketplace.Codeunit.al491 lines, Copyright (c) Microsoft Corporation. MIT

// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------

namespace System.Apps;

using System;
using System.Utilities;
using System.Azure.KeyVault;
using System.Environment;
using System.Environment.Configuration;

/// <summary>
/// This codeunit is used as a helper for managing interactions between
/// Business Central and the AppSource marketplace. The marketplace provides a gallery
/// that users can use to select and install Extensions published (an thus
/// available) on Business Central.
/// When and item is selected from the gallery, a JSON object is returned
/// and needs to be parsed for the key information we need to perform an
/// install.
/// At current the key pieces of that object look like this:
/// "msgType":"&lt;type name string&gt;",
/// "data":
///    "applicationId":"&lt;string identifier for selected extension&gt;",
///    "telemetryUrl":"&lt;url&gt;",
/// </summary>
codeunit 2501 "Extension Marketplace"
{
    Access = Internal;
    Permissions = tabledata "Extension Pending Setup" = rimd,
                  tabledata "Published Application" = r;

    var
        HttpWebRequest: DotNet HttpWebRequest;
        GlobalPropertyValue: Text;
        ParseFailureErr: Label 'Failed to extract ''%1'' property from JSON object.', Comment = 'JSON parsing error. %1=target property name';
        TelemetryBodyTxt: Label '{"acquisitionResult":"%1", "detail":"%2"}', Comment = '%1=AppSource operation result option, %2=details describing the context or reason for the result', Locked = true;
        ParseApplicationIdErr: Label 'Failed to extract ''%1'' token from Application Id.', Comment = '%1=Name of token that we expected   ';
        Token: Option PUBID,AID,PAPPID;
        MarketplaceDisabledSecretTxt: Label 'extmgmt-marketplace-disable', Locked = true;
        MarketPlaceSuccInstallTxt: Label 'The extension was successfully installed.';
        MarketPlaceUnsuccInstallTxt: Label 'The market place extension installation has failed with the result ''%1''. Error message: ''%2''', Comment = '%1 - OperationResult parameter value, %2 - Error message';
        AlreadyInstalledMsg: Label 'The extension %1 is already installed.', Comment = '%1=name of app';
#if not CLEAN25
        AppsourceTxt: Label 'https://appsource.microsoft.com', Locked = true;
#endif        
        ExtensionNotFoundErr: Label 'Selected extension could not be installed because a valid App Id is not passed.', Comment = 'Error message for trying to install an extension where a valid id is not passed;';
        TelemetryExtensionNotFoundErr: Label 'Selected extension could not be installed because a valid App Id is not passed. Application ID : %1.', Comment = 'Telemetry error message for trying to install an extension a valid id is not passed; %1 is the applicaiton id recieved from appsource.';
        MissingAppIdErr: Label 'Selected extension could not be installed because the extension is not published and a valid App Id is not passed. Application ID : %1.', Comment = 'Telemetry error message for trying to install an extension a valid id is not passed; %1 is the applicaiton id recieved from appsource.';
        TelemetryTok: Label 'ExtensionManagementTelemetryCategoryTok', Locked = true;
        OperationResult: Option UserNotAuthorized,DeploymentFailedDueToPackage,DeploymentFailed,Successful,UserCancel,UserTimeOut;
        AppDoesntNeedSetupMsg: Label 'Your app is installed and ready to use.';

    local procedure GetValue(JObject: DotNet JObject; Property: Text; ThrowError: Boolean): Text
    begin
        // Helper for extracting a property value out of a JObject
        if TryGetValue(JObject, Property) then
            exit(GlobalPropertyValue);

        if ThrowError then
            Error(ParseFailureErr, Property);

        exit('');
    end;

    [TryFunction]
    local procedure TryGetValue(JObject: DotNet JObject; Property: Text)
    var
        StringComparison: DotNet StringComparison;
        JToken: DotNet JToken;
    begin
        // Helper to 'safely' extract the value of a JProperty. Ignores case and 'catches' exceptions
        JToken := JObject.GetValue(Property, StringComparison.OrdinalIgnoreCase);
        GlobalPropertyValue := JToken.ToString();
    end;

    procedure GetTelementryUrlFromData(JObject: DotNet JObject): Text
    var
        TempObject: DotNet JObject;
    begin
        // Extracts the telemetryUrl property, out of the data object, return by the AppSource site
        // NOTE: the temp object is needed here. While JObject.Parse looks like a static call
        // to the JObject type, it will in fact reload and modify the underlying referenced object
        // as well as return the result of a 'parse'
        TempObject := TempObject.Parse(GetValue(JObject, 'data', false));
        exit(GetValue(TempObject, 'responseUrl', false));
    end;

    [TryFunction]
    local procedure TryMakeMarketplaceTelemetryCallback(ResponseURL: Text; InstallationResult: Option UserNotAuthorized,DeploymentFailedDueToPackage,DeploymentFailed,Successful,UserCancel,UserTimeOut)
    var
        TempBlob: Codeunit "Temp Blob";
        HttpWebResponse: DotNet HttpWebResponse;
        ResponseInStream: InStream;
    begin
        InitializeHTTPRequest(ResponseURL);
        HttpWebRequest.Accept := '*/*';
        HttpWebRequest.ContentType := 'application/json';
        HttpWebRequest.Method := 'POST';
        AddBodyAsText(Format(InstallationResult));
        TempBlob.CreateInStream(ResponseInStream);

        ClearLastError();
        HttpWebResponse := HttpWebRequest.GetResponse();
        HttpWebResponse.GetResponseStream().CopyTo(ResponseInStream);
    end;

    [TryFunction]
    local procedure TryParseMarketplaceApplicationId(MarketplaceApplicationId: Text; ExpectedToken: Option PUBID,AID,PAPPID; var GlobalId: Text)
    var
        actualToken: Text;
        TokenFound: Boolean;
        CurrentToken: Text;
    begin
        // Extract token value from format: PUBID.<value>|AID.<value>|PAPPID.<app id>{|-preview}

        // Since 'split' in AL depends on comma delimiters, make sure we remove existing commas
        GlobalId := ConvertStr(MarketplaceApplicationId, ',', ';');

        // Create 'split' points at pipes
        GlobalId := ConvertStr(GlobalId, '|', ',');

        // Flag to indicate if expected token found or not
        TokenFound := false;

        // Iterate over tokens
        while GlobalId <> '' do begin
            CurrentToken := SelectStr(1, GlobalId);

            // Remove the scanned token from GlobalID
            GlobalId := DelStr(GlobalId, 1, StrLen(CurrentToken) + 1);

            // Create 'split' point at token\value separator
            CurrentToken := ConvertStr(CurrentToken, '.', ',');

            // Get token
            actualToken := SelectStr(1, CurrentToken);
            if actualToken = Format(ExpectedToken) then begin
                TokenFound := true;
                break;
            end;
        end;

        if not TokenFound then
            Error(ParseApplicationIdErr, ExpectedToken);

        // Select the value of the token
        GlobalId := SelectStr(2, CurrentToken);
    end;

    procedure MapMarketplaceIdToAppId(MarketplaceApplicationId: Text): Guid
    var
        GlobalId: Text;
        NullGuid: Guid;
    begin
        // When an ISV submits an Extension to AppSource for publication to
        // the marketplace, their artifact (.app) is associated with an
        // ID created internally by the AppSource team.
        // The .app and other associated data are then submitted to our
        // Certification/Validation service. The id created by AppSource for
        // this item is included as part of this payload. Unfortunately,
        // the id isn't provided to our service using the same name: 'applicationId'.
        // It is currently not known what name is used during this initial
        // submission, but once known it will be our responsibility to create
        // a mapping path between that service, the extension, and this codeunit.
        // Format:
        // PUBID.<value>|AID.<value>|PAPPID.<package id>{|-preview}

        if TryParseMarketplaceApplicationId(MarketplaceApplicationId, Token::PAPPID, GlobalId) then
            exit(GlobalId);

        exit(NullGuid);
    end;

    procedure MakeMarketplaceTelemetryCallback(ResponseURL: Text; InstallationResult: Option UserNotAuthorized,DeploymentFailedDueToPackage,DeploymentFailed,Successful,UserCancel,UserTimeOut)
    begin
        if not TryMakeMarketplaceTelemetryCallback(ResponseURL, InstallationResult) then
            if InstallationResult = OperationResult::Successful then
                Session.LogMessage('00008LZ', MarketPlaceSuccInstallTxt, Verbosity::Normal, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, 'Category', 'Extensions')
            else
                Session.LogMessage('00008M0', StrSubstNo(MarketPlaceUnsuccInstallTxt, InstallationResult, GetLastErrorText()), Verbosity::Warning, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, 'Category', 'AL Extensions');
    end;

    procedure InstallMarketplaceExtension(ApplicationId: Guid; ResponseURL: Text; lcid: Integer; PreviewKey: Text)
    var
        PublishedApplication: Record "Published Application";
        ExtensionInstallationImpl: Codeunit "Extension Installation Impl";
        ExtensionOperationImpl: Codeunit "Extension Operation Impl";
    begin
        if IsNullGuid(ApplicationId) then
            Error(TelemetryExtensionNotFoundErr, ApplicationId);

        if PreviewKey <> '' then begin
            // Preview keys are not saved on the data plane and we must then trigger the full deploy operation.
            ExtensionOperationImpl.DeployExtension(ApplicationId, lcid, true, PreviewKey);
            exit;
        end;

        PublishedApplication.SetRange("Package ID", ExtensionOperationImpl.GetLatestVersionPackageIdByAppId(ApplicationId));
        PublishedApplication.SetRange("Tenant Visible", true);
        if not PublishedApplication.FindFirst() then begin
            // If the extension is not found, send the request to the regional service.
            ExtensionOperationImpl.DeployExtension(ApplicationId, lcid, true, '');
            exit;
        end;

        // Check if the extension is already installed
        if ExtensionInstallationImpl.IsInstalledByAppId(ApplicationId) then begin
            Message(StrSubstNo(AlreadyInstalledMsg, PublishedApplication.Name));
            exit;
        end;

        // If it is a first party extension, install it locally
        if IsFirstPartyExtension(PublishedApplication) then
            InstallApp(PublishedApplication."Package ID", PublishedApplication.ID, ResponseURL, lcid)
        else
            // If the extension is found and it's from a third party, then send the request to regional service.
            ExtensionOperationImpl.DeployExtension(ApplicationId, lcid, true, '');
    end;

    [TryFunction]
    procedure InstallAppsourceExtension(MarketplaceApplicationId: Text; TelemetryURL: Text);
    var
        ExtensionInstallationRecord: Record "Extension Installation";
        ExtensionInstallationPage: Page "Extension Installation";
        AppId: Guid;
    begin
        AppId := MapMarketplaceIdToAppId(MarketplaceApplicationId);
        if IsNullGuid(AppId) then begin
            Session.LogMessage('0088BQQ', StrSubstNo(MissingAppIdErr, MarketplaceApplicationId), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', TelemetryTok);
            Error(ExtensionNotFoundErr);
        end;

        ExtensionInstallationRecord.SetRange(ID, AppId);
        ExtensionInstallationRecord.ID := AppId;
        ExtensionInstallationRecord.ResponseUrl := CopyStr(TelemetryURL, 1, MaxStrLen(ExtensionInstallationRecord.ResponseUrl));
        ExtensionInstallationPage.SetRecord(ExtensionInstallationRecord);
        ExtensionInstallationPage.RunModal();
    end;

    [TryFunction]
    procedure InstallAppsourceExtension(AppId: Guid; TelemetryURL: Text)
    var
        ExtensionInstallationRecord: Record "Extension Installation";
        ExtensionInstallationPage: Page "Extension Installation";
    begin
        if IsNullGuid(AppId) then begin
            Session.LogMessage('0000I4S', StrSubstNo(MissingAppIdErr, AppId), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', TelemetryTok);
            Error(ExtensionNotFoundErr);
        end;

        ExtensionInstallationRecord.SetRange(ID, AppId);
        ExtensionInstallationRecord.ID := AppId;
        ExtensionInstallationRecord.ResponseUrl := CopyStr(TelemetryURL, 1, MaxStrLen(ExtensionInstallationRecord.ResponseUrl));
        ExtensionInstallationPage.SetRecord(ExtensionInstallationRecord);
        ExtensionInstallationPage.RunModal();
    end;

    procedure InstallAppsourceExtensionWithRefreshSession(MarketplaceApplicationID: Text; TelemetryURL: Text);
    var
        ExtensionPendingSetup: Record "Extension Pending Setup";
        ExtensionInstallationImpl: Codeunit "Extension Installation Impl";
        MySessionSettings: SessionSettings;
        AppId: Guid;
    begin
        ExtensionInstallationImpl.CheckPermissions();

        if not InstallAppsourceExtension(MarketplaceApplicationID, TelemetryURL) then begin // successful installation returns false
            AppId := MapMarketplaceIdToAppId(MarketplaceApplicationID);
            if ExtensionInstallationImpl.IsInstalledByAppId(AppId) then begin
                SaveExtensionPendingSetup(AppId);
                MySessionSettings.Init();
                MySessionSettings.RequestSessionUpdate(false);
            end else begin
                ExtensionPendingSetup.SetRange("User Id", UserSecurityId());
                ExtensionPendingSetup.DeleteAll();
            end;
        end;
    end;

    procedure InstallAppsourceExtensionWithRefreshSession(AppId: Guid; TelemetryURL: Text);
    var
        ExtensionPendingSetup: Record "Extension Pending Setup";
        ExtensionInstallationImpl: Codeunit "Extension Installation Impl";
        MySessionSettings: SessionSettings;
    begin
        ExtensionInstallationImpl.CheckPermissions();

        if not InstallAppsourceExtension(AppId, TelemetryURL) then // successful installation returns false
            if ExtensionInstallationImpl.IsInstalledByAppId(AppId) then begin
                SaveExtensionPendingSetup(AppId);
                MySessionSettings.Init();
                MySessionSettings.RequestSessionUpdate(false);
            end else begin
                ExtensionPendingSetup.SetRange("User Id", UserSecurityId());
                ExtensionPendingSetup.DeleteAll();
            end;
    end;

    [TryFunction]
    procedure InstallExtension(MarketplaceApplicationID: Text; ResponseURL: Text)
    var
        MarketplaceExtnDeployment: Page "Marketplace Extn Deployment";
        ID: Guid;
    begin
        ID := MapMarketplaceIdToAppId(MarketplaceApplicationID);

        MarketplaceExtnDeployment.SetAppID(ID);
        MarketplaceExtnDeployment.RunModal();

        if MarketplaceExtnDeployment.GetInstalledSelected() then
            InstallMarketplaceExtension(ID, ResponseURL, MarketplaceExtnDeployment.GetLanguageId(), '');
    end;

    procedure IsMarketplaceEnabled(): Boolean
    var
        AzureKeyVault: Codeunit "Azure Key Vault";
        DisabledSecret: Text;
        DisabledValue: Boolean;
    begin
        // Try to retrieve config value from keyvault, but if we fail (not there, or not boolean) then assume true
        if AzureKeyVault.GetAzureKeyVaultSecret(MarketplaceDisabledSecretTxt, DisabledSecret) then
            if Evaluate(DisabledValue, DisabledSecret) then
                exit(not DisabledValue);

        exit(true);
    end;

    procedure InitializeHTTPRequest(URL: Text)
    var
        EnvironmentInformation: Codeunit "Environment Information";
    begin
        if not EnvironmentInformation.IsSaaS() then
            OnOverrideUrl(URL);

        HttpWebRequest := HttpWebRequest.Create(URL);
        SetHttpRequestDefaults();
    end;

    local procedure SetHttpRequestDefaults()
    var
        CookieContainer: DotNet CookieContainer;
    begin
        HttpWebRequest.KeepAlive := true;
        HttpWebRequest.AllowAutoRedirect := true;
        HttpWebRequest.UseDefaultCredentials := true;
        HttpWebRequest.Timeout := 60000;
        CookieContainer := CookieContainer.CookieContainer();
        HttpWebRequest.CookieContainer := CookieContainer;
    end;

    local procedure AddBodyAsText(InstallationResult: Text)
    var
        RequestStr: DotNet Stream;
        StreamWriter: DotNet StreamWriter;
        Encoding: DotNet Encoding;
        BodyText: Text;
    begin
        // BodyText is an enum string that AppSource recognizes, as defined by the OperationResult.
        BodyText := StrSubstNo(TelemetryBodyTxt, InstallationResult, 'ExtensionInstallation');
        RequestStr := HttpWebRequest.GetRequestStream();
        StreamWriter := StreamWriter.StreamWriter(RequestStr, Encoding);
        StreamWriter.Write(BodyText);
        StreamWriter.Flush();
        StreamWriter.Close();
        StreamWriter.Dispose();
    end;

    local procedure InstallApp(PackageId: Guid; ResponseURL: Text; lcid: Integer): Boolean
    var
        ExtensionInstallationImpl: Codeunit "Extension Installation Impl";
        HasSucceeded: Boolean;
    begin
        HasSucceeded := ExtensionInstallationImpl.InstallExtensionWithConfirmDialog(PackageId, lcid);

        if HasSucceeded = true then
            MakeMarketplaceTelemetryCallback(ResponseURL, OperationResult::Successful)
        else
            MakeMarketplaceTelemetryCallback(ResponseURL, OperationResult::DeploymentFailedDueToPackage);

        exit(HasSucceeded);
    end;

    local procedure InstallApp(PackageId: Guid; AppId: Guid; ResponseURL: Text; lcid: Integer)
    begin
        if not InstallApp(PackageId, ResponseURL, lcid) then
            exit;

        RunSetupForExtension(AppId);
    end;

    procedure RunSetupForExtension(AppId: Guid)
    var
        GuidedExperience: Codeunit "Guided Experience";
    begin
        if not GuidedExperience.SetupForExtensionExists(AppId) then begin
            Message(AppDoesntNeedSetupMsg);
            exit;
        end;

        GuidedExperience.RunExtensionSetup(AppId);
    end;

    procedure RunPendingSetup()
    var
        ExtensionPendingSetup: Record "Extension Pending Setup";
        AppToSetup: Guid;
    begin
        ExtensionPendingSetup.SetRange("User Id", UserSecurityId());
        ExtensionPendingSetup.SetRange("Created On", CurrentDateTime - (5 * 60000), CurrentDateTime); // created in the last 5 minutes
        if ExtensionPendingSetup.FindFirst() then begin
            AppToSetup := ExtensionPendingSetup."App Id";
            ExtensionPendingSetup.SetRange("Created On");
            ExtensionPendingSetup.DeleteAll();
            RunSetupForExtension(AppToSetup);
        end else begin
            ExtensionPendingSetup.SetRange("Created On");
            ExtensionPendingSetup.DeleteAll();
        end;
    end;

    local procedure SaveExtensionPendingSetup(AppIdToSave: Guid)
    var
        ExtensionPendingSetup: Record "Extension Pending Setup";
        ExtensionSetupLauncher: Page "Extension Setup Launcher";
    begin
        ExtensionPendingSetup.SetRange("User Id", UserSecurityId());
        ExtensionPendingSetup.DeleteAll();
        Clear(ExtensionPendingSetup);
        ExtensionPendingSetup."User Id" := UserSecurityId();
        ExtensionPendingSetup."Created On" := CurrentDateTime();
        ExtensionPendingSetup."App Id" := AppIdToSave;
        if ExtensionPendingSetup.Insert() then;
        Commit();
        ExtensionSetupLauncher.SetNoSetupOnOpen(true);
        ExtensionSetupLauncher.Run();
    end;

    procedure SetupForExtensionExists(AppId: Guid): Boolean
    var
        GuidedExperience: Codeunit "Guided Experience";
    begin
        exit(GuidedExperience.SetupForExtensionExists(AppId));
    end;

    local procedure IsFirstPartyExtension(PublishedApplication: Record "Published Application"): Boolean
    begin
        if PublishedApplication.Publisher = 'Microsoft' then
            exit(true);

        exit(false);
    end;

#if not CLEAN25
    [Obsolete('Microsoft AppSource apps feature will replace the Extension Marketplace.', '25.0')]
    procedure GetMarketplaceEmbeddedUrl(): Text;
    begin
        exit(AppsourceTxt);
    end;

#endif
    procedure GetMessageType(JObject: DotNet JObject): Text;
    begin
        // Extracts the 'msgType' property from the
        exit(GetValue(JObject, 'msgType', true));
    end;

    procedure GetApplicationIdFromData(JObject: DotNet JObject): Text;
    var
        TempObject: DotNet JObject;
    begin
        // Extracts the applicationId property out of the data object return by the SPZA site
        TempObject := TempObject.Parse(GetValue(JObject, 'data', true));
        exit(GetValue(TempObject, 'applicationId', true));
    end;

    [EventSubscriber(ObjectType::Codeunit, Codeunit::"System Action Triggers", InvokeExtensionInstallation, '', false, false)]
    local procedure InvokeExtensionInstallation(AppId: Text; ResponseUrl: Text)
    begin
        if not InstallExtension(AppId, ResponseUrl) then
            Message(GetLastErrorText());
    end;

    [InternalEvent(false)]
    internal procedure OnOverrideUrl(var Url: Text)
    begin
        // Provides an option to rewrite URL in non SaaS environments.
    end;
}