A Multi-Tenant, SaaS, Web Hosting Service with the Orchard Core CMS Framework
A Web Hosting Service with Orchard Core
YouTube Video
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.
Launch Visual Studio and then "Create a new Project".
Select the "ASP.NET Core Web Application" and then press the "Next" button.
Enter in a project name.
Select "Empty" and then press the "Create" button.
Right click on the solution, select "Add" and then "New Item...".
Select "Text File" and safe the file as "NuGet.config".
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.
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>
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.
Enter in the site name and your credentials and then press the "Finish Setup" button.
Click on the "Log in" menu link.
Enter your credentials and then press the "Log in" button.
Select the Dashboard.
Everything seems to we working.
Right click on the solution, select "Add" and then "New Project...".
Click on "Class Library (.NET Core)" and then press the "Next" button.
Enter in a "Project name" and then press the "Create" button.
Right click on "Class1" and select "Rename...".
Click the check boxes and then press the "Apply" button.
Click the "Apply" button.
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>
Right click on the "Saas" project and then "Add New Item..."
Add the file "Manifest.cs".
Modify the manifest to the following:
using OrchardCore.Modules.Manifest;
[assembly: Module(
Name = "SaaS",
Author = "Orchard Skills",
Website = "http://OrchardSkills.com",
Version = "1.0.0-rc2",
Description = "A SaaS Multi-Tenant website."
)]
Right click on the "Recipe" folder and the "Add New Item..."
Add the file "sass.recipe.json".
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"
}
}
]
}
Add the asset files to the "wwwroot" folder.
Requirements for the Web Hosting Service.
Right click on the "Controllers" folder and then select "Add New Item...".
Add a "HomeController.cs" file.
Right click on the "ViewModels" folder and then select "Add New Item...".
Add the file "RegisterUserViewModel.cs".
Right click on the "Home" folder.
Select "Add New Items...".
Add the file "index.cshtml".
Right click on the "Views" folder and then "Add New Item...".
Add the the file "_ViewImports.cshtml".
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; }
}
}
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
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>
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[type=radio]: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="https://github.com/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="https://github.com/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="https://github.com/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="https://github.com/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="https://github.com/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>
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);
}
[HttpPost]
[ActionName(nameof(Index))]
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[$"Hello,<br><br>Your demo site '{siteName}' has been created.<br><br>1) Setup your site by opening <a href=\"{confirmationLink}\">this link</a>.<br><br>2) Log into the <a href=\"{siteUrl}/admin\">admin</a> with these credentials:<br>Username: {adminName}<br>Password: {adminPassword}"];
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][rand.Next(0, randomChars[0].Length)]);
}
if (opts.RequireLowercase)
{
chars.Insert(rand.Next(0, chars.Count), randomChars[1][rand.Next(0, randomChars[1].Length)]);
}
if (opts.RequireDigit)
{
chars.Insert(rand.Next(0, chars.Count), randomChars[2][rand.Next(0, randomChars[2].Length)]);
}
if (opts.RequireNonAlphanumeric)
{
chars.Insert(rand.Next(0, chars.Count), randomChars[3][rand.Next(0, randomChars[3].Length)]);
}
for (int i = chars.Count; i < opts.RequiredLength || chars.Distinct().Count() < opts.RequiredUniqueChars; i++)
{
string rcs = randomChars[rand.Next(0, randomChars.Length)];
chars.Insert(rand.Next(0, chars.Count), rcs[rand.Next(0, rcs.Length)]);
}
return new string(chars.ToArray());
}
}
}
Click on the green play button to run the application.
Enter the the site name, select the Saas recipe, enter your credentials and then press the "Finish Setup" button.
Login to the Admin Dashboard.
Select Configuration and then Smtp. Enter in the setup configuration.
Press the "Test settings" button.
Send a test message.
Message sent successfully.
Go back to the website and signup for a hosting site.
Congratulations! Site created successfully.
Go to your email and click on the "Setup your by opening this link".
Your site is now activated.
Conclusion
With the powerful features of 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.