A Multi-Tenant, SaaS, Web Hosting Service with the Orchard Core CMS Framework

A Web Hosting Service with Orchard Core

YouTube Video

OrchardSkillsYouTubeThumbNailSaaS

Introduction

A web hosting service is a type of Internet hosting service that allows individuals and organizations to make their website accessible via the World Wide Web. We will be creating a Multi-Tenant, SaaS, Web Hosting Service with the Orchard Core CMS Framework.

SaaS-001

Launch Visual Studio and then "Create a new Project".

SaaS-002

Select the "ASP.NET Core Web Application" and then press the "Next" button.

SaaS-003

Enter in a project name.

SaaS-004

Select "Empty" and then press the "Create" button.

SaaS-005

Right click on the solution, select "Add" and then "New Item...".

SaaS-006

Select "Text File" and safe the file as "NuGet.config".

SaaS-007

Enter the NuGet feed. Note: this is the RC2 configuration.

<?xml version="1.0" encoding="utf-8"?>
<configuration>
	<packageSources>
		<clear />
		<add key="NuGet" value="https://api.nuget.org/v3/index.json" />
		<!-- add key="OrchardCorePreview" value="https://nuget.cloudsmith.io/orchardcore/preview/v3/index.json" / -->
	</packageSources>
	<disabledPackageSources />
</configuration>

Restart Visual Studio so that the NuGet configuration is used.

SaaS-008

Open the Web Application project. Add the "CMS Targets". Note this is RC2.

  <ItemGroup>
    <PackageReference Include="OrchardCore.Application.Cms.Targets" Version="1.0.0-rc2-13450" />
  </ItemGroup>

SaaS-009

Modify the "Startup.cs" file.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace OrchardSkills.OrchardCore.OrchardCMS
{
    public class Startup
    {
        // This method gets called by the runtime. Use this method to add services to the container.
        // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddOrchardCms();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseOrchardCore();

        }
    }
}

Run the application by clicking on the green play button.

SaaS-010

Enter in the site name and your credentials and then press the "Finish Setup" button.

SaaS-011

Click on the "Log in" menu link.

SaaS-012

Enter your credentials and then press the "Log in" button.

SaaS-013

Select the Dashboard.

SaaS-014

Everything seems to we working.

SaaS-015

Right click on the solution, select "Add" and then "New Project...".

SaaS-016

Click on "Class Library (.NET Core)" and then press the "Next" button.

SaaS-017

Enter in a "Project name" and then press the "Create" button.

SaaS-018

Right click on "Class1" and select "Rename...".

SaaS-019

Click the check boxes and then press the "Apply" button.

SaaS-020

Click the "Apply" button.

SaaS-021

Click on the "SaaS" project and modify it to the following: Node this configuration is for RC2.

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <AddRazorSupportForMvc>true</AddRazorSupportForMvc>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="OrchardCore.Admin.Abstractions" Version="1.0.0-rc2-13450" />
    <PackageReference Include="OrchardCore.Email.Abstractions" Version="1.0.0-rc2-13450" />
    <PackageReference Include="OrchardCore.DisplayManagement" Version="1.0.0-rc2-13450" />
    <PackageReference Include="OrchardCore.Data.Abstractions" Version="1.0.0-rc2-13450" />
    <PackageReference Include="OrchardCore.Module.Targets" Version="1.0.0-rc2-13450" />
    <PackageReference Include="OrchardCore.Recipes.Abstractions" Version="1.0.0-rc2-13450" />
    <PackageReference Include="OrchardCore.ResourceManagement" Version="1.0.0-rc2-13450" />
    <PackageReference Include="OrchardCore.Setup.Abstractions" Version="1.0.0-rc2-13450" />
    <PackageReference Include="OrchardCore.Setup" Version="1.0.0-rc2-13450" />
  </ItemGroup>  
  
</Project>

SaaS-023

Right click on the "Saas" project and then "Add New Item..."

SaaS-024

Add the file "Manifest.cs".

SaaS-025

Modify the manifest to the following:

using OrchardCore.Modules.Manifest;


SaaS-026

Right click on the "Recipe" folder and the "Add New Item..."

SaaS-027

Add the file "sass.recipe.json".

SaaS-028

Modify the recipe to as follows:

{
  "name": "SaaS",
  "displayName": "SaaS",
  "description": "A SaaS Multi-Tenant website.",
  "author": "Orchard Skills",
  "website": "http://orchardskills.net",
  "version": "1.0.0-rc2",
  "issetuprecipe": true,
  "categories": [ "default" ],
  "tags": [ "developer", "default" ],
  "steps": [
    {
      "name": "feature",
      "disable": [],
      "enable": [
        "OrchardCore.Admin",
        "OrchardCore.Diagnostics",
        "OrchardCore.Email",
        "OrchardCore.HomeRoute",
        "OrchardCore.Localization",
        "OrchardCore.Features",
        "OrchardCore.Navigation",
        "OrchardCore.Recipes",
        "OrchardCore.Resources",
        "OrchardCore.Roles",
        "OrchardCore.Settings",
        "OrchardCore.Tenants",
        "OrchardCore.Themes",
        "SaaS",
        "OrchardCore.Users",

        // Themes
        "TheTheme",
        "TheAdmin",
        "SafeMode"
      ]
    },
    {
      "name": "themes",
      "admin": "TheAdmin",
      "site": "TheTheme"
    },
    {
      "name": "settings",
      "HomeRoute": {
        "Action": "Index",
        "Controller": "Home",
        "Area": "SaaS"
      }
    }
  ]
}

SaaS-029

Add the asset files to the "wwwroot" folder.

SaaS-030

Requirements for the Web Hosting Service.

SaaS-031

Right click on the "Controllers" folder and then select "Add New Item...".

SaaS-032

Add a "HomeController.cs" file.

SaaS-033

Right click on the "ViewModels" folder and then select "Add New Item...".

SaaS-034

Add the file "RegisterUserViewModel.cs".

SaaS-035

Right click on the "Home" folder.

SaaS-036

Select "Add New Items...".

SaaS-037

Add the file "index.cshtml".

SaaS-038

Right click on the "Views" folder and then "Add New Item...".

SaaS-039

Add the the file "_ViewImports.cshtml".

SaaS-040

Add the following to the file "RegisterUserViewModel.cs".

namespace SaaS.ViewModels
{
    public class RegisterUserViewModel
    {
        public string SiteName { get; set; }
        public string Handle { get; set; }
        public string Email { get; set; }
        public string RecipeName { get; set; }
        public bool AcceptTerms { get; set; }
    }
}

SaaS-041

Add the following to the file "_ViewImports.cshtml".

@inherits OrchardCore.DisplayManagement.Razor.RazorPage<TModel>
@using SaaS
@using SaaS.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, OrchardCore.DisplayManagement
@addTagHelper *, OrchardCore.ResourceManagement

SaaS-042

Add the following to the "Success.cshtml" file.

<div class="jumbotron">
  <h1 class="display-4">@T["Site created successfully."]</h1>
  <p class="lead">@T["An email has been sent to the provided address. Please check your inbox (or spam folder) and follow the instructions."]</p>
  <hr class="my-4">
  <p>@T["This email contains a link to setup your site and the credentials to access the admin."]</p>
</div>

SaaS-043

Add the following to the "Index.cshtml" file.

@model RegisterUserViewModel
<style>
    .hidden {
        display: none;
    }

    .jumbotron {
        background: url("/SaaS/images/OrchardCore-2560x1440.png") no-repeat center center;
        background-size: cover;
        width: 100%;
        padding-top: 20%;
        padding-bottom: 20%;
    }


    input:checked + label > img {
        border: 1px solid #fff;
        box-shadow: 0 0 3px 3px #090;
    }
</style>

<div class="jumbotron">
</div>

<div>
<h2>Web Hosting, A SaaS Muti-Tenant Website</h2>
</div>
<form asp-action="Index" method="post">
    <input asp-for="Handle" type="hidden" />
    <div class="form-group">
        <label asp-for="Email">@T["Email"]</label>
        <small class="text-muted">- Please provide a valid email address to send you a link that will activate the site.</small>
        <input class="form-control" asp-for="Email" type="email" required />
    </div>

    <div class="form-group">
        <label asp-for="SiteName">@T["Site Name"]</label>
        <small class="text-muted">- The name of the site that will be created.</small>
        <input class="form-control" asp-for="SiteName" required />
    </div>

    <div class="form-group">
        <label asp-for="RecipeName">@T["Recipe"]</label>
        <small class="text-muted">- Orchard Core websites can be preconfigured with custom content and templates. The following examples will give you an idea of what you can build with it.</small>
        <div class="row">
            <div class="col-md-4 box">
                <input id="agency" type="radio" name="RecipeName" value="Agency" class="hidden" autocomplete="off" checked>
                <label class="btn btn-light" for="agency">
                    <img src="/SaaS/themes/agency.jpg" title="Agency" class="img-thumbnail img-check">
                </label>
            </div>
            <div class="col-md-4 box">
                <input id="blog" type="radio" name="RecipeName" value="Blog" class="hidden" autocomplete="off">
                <label class="btn btn-light" for="blog">
                    <img src="/SaaS/themes/blog.jpg" title="Blog" class="img-thumbnail img-check">
                </label>
            </div>
            <div class="col-md-4 box">
                <input id="comingsoon" type="radio" name="RecipeName" value="ComingSoon" class="hidden" autocomplete="off">
                <label class="btn btn-light" for="comingsoon">
                    <img src="/SaaS/themes/comingsoon.jpg" title="Coming soon" class="img-thumbnail img-check">
                </label>
            </div>
            <div class="clearfix"></div>
        </div>
    </div>

    <div class="form-group form-check">
        <input type="checkbox" class=" form-check-input" asp-for="AcceptTerms" required />
        <label class="form-check-label" asp-for="AcceptTerms">@T["I agree with the"] <a href="/terms">@T["Terms and Conditions"]</a></label>
        <small class="text-muted">We use the email address to create a demo site. Here is our <a href="/privacy">Privacy Policy</a>.</small>
    </div>
    <div class="form-group">
        <button type="submit" class="btn btn-primary">@T["Submit"]</button>
    </div>

    @Html.ValidationSummary(false, "", new { @class = "alert-danger" })
</form>
<script at="Foot">$("#navbar").remove();</script>

SaaS-044

Add the following to the "HomeController.cs" file.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OrchardCore.Email;
using OrchardCore.Environment.Shell;
using OrchardCore.Environment.Shell.Models;
using OrchardCore.Modules;
using OrchardCore.Setup.Services;
using SaaS.ViewModels;

namespace SaaS.Controllers
{
    public class HomeController : Controller
    {
        private const string defaultAdminName = "admin";
        private const string dataProtectionPurpose = "Password";
        private const string emailSubject = "SaaS Registration";
        private const bool emailToBcc = true;

        private readonly IShellSettingsManager _shellSettingsManager;
        private readonly IShellHost _shellHost;
        private readonly ISmtpService _smtpService;
        private readonly ISetupService _setupService;
        private readonly IOptions<SmtpSettings> _smtpSettingsOptions;
        private readonly IClock _clock;
        private readonly IDataProtectionProvider _dataProtectionProvider;
        private readonly ILogger<HomeController> _logger;

        public HomeController(
            IShellSettingsManager shellSettingsManager,
            IShellHost shellHost,
            ISmtpService smtpService,
            ISetupService setupService,
            IOptions<SmtpSettings> smtpSettingsOptions,
            IClock clock,
            IDataProtectionProvider dataProtectionProvider,
            ILogger<HomeController> logger,
            IStringLocalizer<HomeController> stringLocalizer)
        {
            _shellSettingsManager = shellSettingsManager;
            _shellHost = shellHost;
            _smtpService = smtpService;
            _setupService = setupService;
            _smtpSettingsOptions = smtpSettingsOptions;
            _clock = clock;
            _dataProtectionProvider = dataProtectionProvider;
            _logger = logger;

            S = stringLocalizer;
        }

        public IStringLocalizer S { get; set; }

        public IActionResult Index(RegisterUserViewModel model)
        {
            // Generate random site prefix
            model.Handle = GenerateRandomName();

            return View(model);
        }

        
        
        public async Task<IActionResult> IndexPost(RegisterUserViewModel model)
        {
            if (!model.AcceptTerms)
            {
                ModelState.AddModelError(nameof(RegisterUserViewModel.AcceptTerms), S["Please, accept the terms and conditions."]);
            }

            if (!string.IsNullOrEmpty(model.Handle) && !Regex.IsMatch(model.Handle, @"^\w+$"))
            {
                ModelState.AddModelError(nameof(RegisterUserViewModel.Handle), S["Invalid tenant name. Must contain characters only and no spaces."]);
            }

            if (ModelState.IsValid)
            {
                if (_shellHost.TryGetSettings(model.Handle, out var shellSettings))
                {
                    ModelState.AddModelError(nameof(RegisterUserViewModel.Handle), S["This site name already exists."]);
                }
                else
                {
                    shellSettings = new ShellSettings
                    {
                        Name = model.Handle,
                        RequestUrlPrefix = model.Handle.ToLower(),
                        RequestUrlHost = null,
                        State = TenantState.Uninitialized
                    };
                    shellSettings["RecipeName"] = model.RecipeName;
                    shellSettings["DatabaseProvider"] = "Sqlite";

                    await _shellSettingsManager.SaveSettingsAsync(shellSettings);
                    var shellContext = await _shellHost.GetOrCreateShellContextAsync(shellSettings);

                    var recipes = await _setupService.GetSetupRecipesAsync();
                    var recipe = recipes.FirstOrDefault(x => x.Name == model.RecipeName);

                    if (recipe == null)
                    {
                        ModelState.AddModelError(nameof(RegisterUserViewModel.RecipeName), S["Invalid recipe name."]);
                    }

                    var adminName = defaultAdminName;
                    var adminPassword = GenerateRandomPassword();
                    var siteName = model.SiteName;
                    var siteUrl = GetTenantUrl(shellSettings);

                    var dataProtector = _dataProtectionProvider.CreateProtector(dataProtectionPurpose).ToTimeLimitedDataProtector();
                    var encryptedPassword = dataProtector.Protect(adminPassword, _clock.UtcNow.Add(new TimeSpan(24, 0, 0)));
                    var confirmationLink = Url.Action(nameof(Confirm), "Home", new { email = model.Email, handle = model.Handle, siteName = model.SiteName, ep = encryptedPassword }, Request.Scheme);

                    var message = new MailMessage();
                    if (emailToBcc)
                    {
                        message.Bcc = _smtpSettingsOptions.Value.DefaultSender;
                    }
                    message.To = model.Email;
                    message.IsBodyHtml = true;
                    message.Subject = emailSubject;
                    message.Body = S;

                    await _smtpService.SendAsync(message);

                    return RedirectToAction(nameof(Success));
                }
            }

            return View(nameof(Index), model);
        }

        public IActionResult Success()
        {
            return View();
        }

        public async Task<IActionResult> Confirm(string email, string handle, string siteName, string ep)
        {
            if (!_shellHost.TryGetSettings(handle, out var shellSettings))
            {
                return NotFound();
            }

            if (shellSettings.State == TenantState.Uninitialized)
            {
                var recipes = await _setupService.GetSetupRecipesAsync();
                var recipe = recipes.FirstOrDefault(x => x.Name == shellSettings["RecipeName"]);

                if (recipe == null)
                {
                    return NotFound();
                }

                var password = Decrypt(ep);

                var setupContext = new SetupContext
                {
                    ShellSettings = shellSettings,
                    SiteName = siteName,
                    EnabledFeatures = null,
                    AdminUsername = defaultAdminName,
                    AdminEmail = email,
                    AdminPassword = password,
                    Errors = new Dictionary<string, string>(),
                    Recipe = recipe,
                    SiteTimeZone = _clock.GetSystemTimeZone().TimeZoneId,
                    DatabaseProvider = shellSettings["DatabaseProvider"],
                    //DatabaseConnectionString = shellSettings["ConnectionString"],
                    //DatabaseTablePrefix = shellSettings["TablePrefix"]
                };

                var executionId = await _setupService.SetupAsync(setupContext);

                // Check if a component in the Setup failed
                if (setupContext.Errors.Any())
                {
                    foreach (var error in setupContext.Errors)
                    {
                        ModelState.AddModelError(error.Key, error.Value);
                    }

                    return Redirect("Error");
                }
            }

            return Redirect("~/" + handle);
        }

        private string Decrypt(string encryptedString)
        {
            try
            {
                var dataProtector = _dataProtectionProvider.CreateProtector(dataProtectionPurpose).ToTimeLimitedDataProtector();

                return dataProtector.Unprotect(encryptedString, out var expiration);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error decrypting the string");
            }

            return null;
        }

        public string GetTenantUrl(ShellSettings shellSettings)
        {
            var requestHostInfo = Request.Host;

            var tenantUrlHost = shellSettings.RequestUrlHost?.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries).First() ?? requestHostInfo.Host;
            if (requestHostInfo.Port.HasValue)
            {
                tenantUrlHost += ":" + requestHostInfo.Port;
            }

            var result = $"{Request.Scheme}://{tenantUrlHost}";

            if (!string.IsNullOrEmpty(shellSettings.RequestUrlPrefix))
            {
                result += "/" + shellSettings.RequestUrlPrefix;
            }

            return result;
        }

        public string GenerateRandomName()
        {
            return Path.GetRandomFileName().Replace(".", "").Substring(0, 8);
        }

        public string GenerateRandomPassword(PasswordOptions opts = null)
        {
            if (opts == null) opts = new PasswordOptions()
            {
                RequiredLength = 8,
                RequiredUniqueChars = 4,
                RequireDigit = true,
                RequireLowercase = true,
                RequireNonAlphanumeric = true,
                RequireUppercase = true
            };

            string[] randomChars = new[] {
            "ABCDEFGHJKLMNOPQRSTUVWXYZ",    // uppercase 
            "abcdefghijkmnopqrstuvwxyz",    // lowercase
            "0123456789",                   // digits
            "!@$?_-"                        // non-alphanumeric
            };

            Random rand = new Random(System.Environment.TickCount);
            List<char> chars = new List<char>();

            if (opts.RequireUppercase)
            {
                chars.Insert(rand.Next(0, chars.Count), randomChars[0].Length)]);
            }

            if (opts.RequireLowercase)
            {
                chars.Insert(rand.Next(0, chars.Count), randomChars[1].Length)]);
            }

            if (opts.RequireDigit)
            {
                chars.Insert(rand.Next(0, chars.Count), randomChars[2].Length)]);
            }

            if (opts.RequireNonAlphanumeric)
            {
                chars.Insert(rand.Next(0, chars.Count), randomChars[3].Length)]);
            }

            for (int i = chars.Count; i < opts.RequiredLength || chars.Distinct().Count() < opts.RequiredUniqueChars; i++)
            {
                string rcs = randomChars;
                chars.Insert(rand.Next(0, chars.Count), rcs);
            }

            return new string(chars.ToArray());
        }
    }
}

SaaS-045

Click on the green play button to run the application.

SaaS-046

Enter the the site name, select the Saas recipe, enter your credentials and then press the "Finish Setup" button.

SaaS-047

Login to the Admin Dashboard.

SaaS-048

SaaS-049

Select Configuration and then Smtp. Enter in the setup configuration.

SaaS-050

Press the "Test settings" button.

SaaS-051

Send a test message.

SaaS-052

Message sent successfully.

SaaS-053

Go back to the website and signup for a hosting site.

SaaS-054

Congratulations! Site created successfully.

SaaS-055

Go to your email and click on the "Setup your by opening this link".

SaaS-056

Your site is now activated.

Conclusion

With the powerful features pf the Orchard Core framework, we were able to create a Multi-Tenant, SaaS, Web Hosting Service with just a single Web Application.

GitHub

The complete source code is located here.