Enhance Settings API for public Site Settings. Added Settings to Site model by default. Added new parameters to Login and UserProfile components. Enhanced Oqtane Theme settings to use new component parameters. Enhanced image download and resizing logic.

This commit is contained in:
Shaun Walker 2021-09-20 17:15:52 -04:00
parent db85e088bf
commit f739db1e42
25 changed files with 458 additions and 188 deletions

View File

@ -27,7 +27,7 @@
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="url" HelpText="Enter the url of the file you wish to download" ResourceKey="Url">Url: </Label> <Label Class="col-sm-3" For="url" HelpText="Enter the url of the file you wish to download" ResourceKey="Url">Url: </Label>
<div class="col-sm-9"> <div class="col-sm-9">
<input id="url" class="form-control" @bind="@url" required /> <input id="url" class="form-control" @bind="@_url" required />
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
@ -42,6 +42,12 @@
</select> </select>
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="name" HelpText="Enter the name of the file being downloaded" ResourceKey="Name">Name: </Label>
<div class="col-sm-9">
<input id="name" class="form-control" @bind="@_name" />
</div>
</div>
</div> </div>
<button type="button" class="btn btn-success" @onclick="Download">@SharedLocalizer["Download"]</button> <button type="button" class="btn btn-success" @onclick="Download">@SharedLocalizer["Download"]</button>
<NavLink class="btn btn-secondary" href="@NavigateUrl()">@SharedLocalizer["Cancel"]</NavLink> <NavLink class="btn btn-secondary" href="@NavigateUrl()">@SharedLocalizer["Cancel"]</NavLink>
@ -53,9 +59,10 @@
@code { @code {
private ElementReference form; private ElementReference form;
private bool validated = false; private bool validated = false;
private string url = string.Empty; private string _url = string.Empty;
private List<Folder> _folders; private List<Folder> _folders;
private int _folderId = -1; private int _folderId = -1;
private string _name = "";
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Admin; public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Admin;
@ -75,22 +82,24 @@
var interop = new Interop(JSRuntime); var interop = new Interop(JSRuntime);
if (await interop.FormValid(form)) if (await interop.FormValid(form))
{ {
if (url == string.Empty || _folderId == -1) if (_url == string.Empty || _folderId == -1)
{ {
AddModuleMessage(Localizer["Message.Required.UrlFolder"], MessageType.Warning); AddModuleMessage(Localizer["Message.Required.UrlFolder"], MessageType.Warning);
return; return;
} }
var filename = url.Substring(url.LastIndexOf("/", StringComparison.Ordinal) + 1); if (string.IsNullOrEmpty(_name))
{
_name = _url.Substring(_url.LastIndexOf("/", StringComparison.Ordinal) + 1);
}
if (!Constants.UploadableFiles.Split(',') if (!Constants.UploadableFiles.Split(',').Contains(Path.GetExtension(_name).ToLower().Replace(".", "")))
.Contains(Path.GetExtension(filename).ToLower().Replace(".", "")))
{ {
AddModuleMessage(Localizer["Message.Download.InvalidExtension"], MessageType.Warning); AddModuleMessage(Localizer["Message.Download.InvalidExtension"], MessageType.Warning);
return; return;
} }
if (!filename.IsPathOrFileValid()) if (!_name.IsPathOrFileValid())
{ {
AddModuleMessage(Localizer["Message.Required.UrlName"], MessageType.Warning); AddModuleMessage(Localizer["Message.Required.UrlName"], MessageType.Warning);
return; return;
@ -98,13 +107,13 @@
try try
{ {
await FileService.UploadFileAsync(url, _folderId); await FileService.UploadFileAsync(_url, _folderId, _name);
await logger.LogInformation("File Downloaded Successfully From Url {Url}", url); await logger.LogInformation("File Downloaded Successfully From Url {Url}", _url);
AddModuleMessage(Localizer["Success.Download.File"], MessageType.Success); AddModuleMessage(Localizer["Success.Download.File"], MessageType.Success);
} }
catch (Exception ex) catch (Exception ex)
{ {
await logger.LogError(ex, "Error Downloading File From Url {Url} {Error}", url, ex.Message); await logger.LogError(ex, "Error Downloading File From Url {Url} {Error}", _url, ex.Message);
AddModuleMessage(Localizer["Error.Download.InvalidUrl"], MessageType.Error); AddModuleMessage(Localizer["Error.Download.InvalidUrl"], MessageType.Error);
} }
} }

View File

@ -81,6 +81,7 @@
</TabPanel> </TabPanel>
} }
</TabStrip> </TabStrip>
<br />
<button type="button" class="btn btn-success" @onclick="SaveModule">@SharedLocalizer["Save"]</button> <button type="button" class="btn btn-success" @onclick="SaveModule">@SharedLocalizer["Save"]</button>
<NavLink class="btn btn-secondary" href="@NavigateUrl()">@SharedLocalizer["Cancel"]</NavLink> <NavLink class="btn btn-secondary" href="@NavigateUrl()">@SharedLocalizer["Cancel"]</NavLink>
<br /> <br />

View File

@ -162,6 +162,7 @@
<TabPanel Name="ThemeSettings" Heading="Theme Settings" ResourceKey="ThemeSettings"> <TabPanel Name="ThemeSettings" Heading="Theme Settings" ResourceKey="ThemeSettings">
@ThemeSettingsComponent @ThemeSettingsComponent
</TabPanel> </TabPanel>
<br />
} }
</TabStrip> </TabStrip>
<button type="button" class="btn btn-success" @onclick="SavePage">@SharedLocalizer["Save"]</button> <button type="button" class="btn btn-success" @onclick="SavePage">@SharedLocalizer["Save"]</button>

View File

@ -12,7 +12,7 @@
@if (PageState.User != null && photo != null) @if (PageState.User != null && photo != null)
{ {
<img src="@ImageUrl(photofileid, "400x400", "crop")" alt="@displayname" style="max-width: 400px" class="rounded-circle mx-auto d-block"> <img src="@ImageUrl(photofileid, 400, 400, "crop")" alt="@displayname" style="max-width: 400px" class="rounded-circle mx-auto d-block">
} }
else else
{ {

View File

@ -134,9 +134,9 @@ namespace Oqtane.Modules
return Utilities.ContentUrl(PageState.Alias, fileid, asAttachment); return Utilities.ContentUrl(PageState.Alias, fileid, asAttachment);
} }
public string ImageUrl(int fileid, string size, string mode) public string ImageUrl(int fileid, int width, int height, string mode)
{ {
return Utilities.ImageUrl(PageState.Alias, fileid, size, mode); return Utilities.ImageUrl(PageState.Alias, fileid, width, height, mode);
} }
public virtual Dictionary<string, string> GetUrlParameters(string parametersTemplate = "") public virtual Dictionary<string, string> GetUrlParameters(string parametersTemplate = "")

View File

@ -156,4 +156,10 @@
<data name="UploadFiles.Heading" xml:space="preserve"> <data name="UploadFiles.Heading" xml:space="preserve">
<value>Upload Files</value> <value>Upload Files</value>
</data> </data>
<data name="Name.HelpText" xml:space="preserve">
<value>Enter the name of the file being downloaded</value>
</data>
<data name="Name.Text" xml:space="preserve">
<value>Name:</value>
</data>
</root> </root>

View File

@ -309,4 +309,7 @@
<data name="Extend" xml:space="preserve"> <data name="Extend" xml:space="preserve">
<value>Extend</value> <value>Extend</value>
</data> </data>
<data name="Not Specified" xml:space="preserve">
<value>Not Specified</value>
</data>
</root> </root>

View File

@ -1,65 +1,65 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<root> <root>
<!-- <!--
Microsoft ResX Schema Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
Version 2.0 mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
The primary goals of this format is to allow a simple XML format : using a System.ComponentModel.TypeConverter
that is mostly human readable. The generation and parsing of the : and then encoded with base64 encoding.
various data types are done through the TypeConverter classes -->
associated with the data types. <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="root">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true"> <xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType> <xsd:complexType>
@ -117,10 +117,28 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </resheader>
<data name="Title.HelpText" xml:space="preserve"> <data name="Footer.HelpText" xml:space="preserve">
<value>Specify If The Page Footer Should Be Displayed</value> <value>Specify if a Footer pane should always be displayed in a fixed location at the bottom of the page.</value>
</data> </data>
<data name="Title.Text" xml:space="preserve"> <data name="Footer.Text" xml:space="preserve">
<value>Display Footer?</value> <value>Display Footer?</value>
</data> </data>
<data name="Login.HelpText" xml:space="preserve">
<value>Specify if a Login option should be displayed, Note that this option does not prevent the login page from being accessible via a direct url.</value>
</data>
<data name="Login.Text" xml:space="preserve">
<value>Show Login?</value>
</data>
<data name="Page" xml:space="preserve">
<value>Page</value>
</data>
<data name="Register.HelpText" xml:space="preserve">
<value>Specify if a Register option should be displayed. Note that this option is also dependent on the Allow Registration option in Site Settings.</value>
</data>
<data name="Register.Text" xml:space="preserve">
<value>Show Register?</value>
</data>
<data name="Site" xml:space="preserve">
<value>Site</value>
</data>
</root> </root>

View File

@ -67,9 +67,9 @@ namespace Oqtane.Services
await DeleteAsync($"{Apiurl}/{fileId}"); await DeleteAsync($"{Apiurl}/{fileId}");
} }
public async Task<File> UploadFileAsync(string url, int folderId) public async Task<File> UploadFileAsync(string url, int folderId, string name)
{ {
return await GetJsonAsync<File>($"{Apiurl}/upload?url={WebUtility.UrlEncode(url)}&folderid={folderId}"); return await GetJsonAsync<File>($"{Apiurl}/upload?url={WebUtility.UrlEncode(url)}&folderid={folderId}&name={name}");
} }
public async Task<string> UploadFilesAsync(int folderId, string[] files, string id) public async Task<string> UploadFilesAsync(int folderId, string[] files, string id)

View File

@ -62,8 +62,9 @@ namespace Oqtane.Services
/// </summary> /// </summary>
/// <param name="url"></param> /// <param name="url"></param>
/// <param name="folderId"></param> /// <param name="folderId"></param>
/// <param name="name"></param>
/// <returns></returns> /// <returns></returns>
Task<File> UploadFileAsync(string url, int folderId); Task<File> UploadFileAsync(string url, int folderId, string name);
/// <summary> /// <summary>
/// Upload one or more files. /// Upload one or more files.

View File

@ -1,4 +1,4 @@
using Oqtane.Models; using Oqtane.Models;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -51,5 +51,9 @@ namespace Oqtane.Services
string GetSetting(Dictionary<string, string> settings, string settingName, string defaultValue); string GetSetting(Dictionary<string, string> settings, string settingName, string defaultValue);
Dictionary<string, string> SetSetting(Dictionary<string, string> settings, string settingName, string settingValue); Dictionary<string, string> SetSetting(Dictionary<string, string> settings, string settingName, string settingValue);
}
Dictionary<string, string> SetSetting(Dictionary<string, string> settings, string settingName, string settingValue, bool isPublic);
Dictionary<string, string> MergeSettings(Dictionary<string, string> settings1, Dictionary<string, string> settings2);
}
} }

View File

@ -109,21 +109,31 @@ namespace Oqtane.Services
foreach (KeyValuePair<string, string> kvp in settings) foreach (KeyValuePair<string, string> kvp in settings)
{ {
Setting setting = settingsList.FirstOrDefault(item => item.SettingName.Equals(kvp.Key,StringComparison.OrdinalIgnoreCase)); string value = kvp.Value;
bool ispublic = false;
if (value.StartsWith("[Public]"))
{
value = value.Substring(8); // remove [Public]
ispublic = true;
}
Setting setting = settingsList.FirstOrDefault(item => item.SettingName.Equals(kvp.Key, StringComparison.OrdinalIgnoreCase));
if (setting == null) if (setting == null)
{ {
setting = new Setting(); setting = new Setting();
setting.EntityName = entityName; setting.EntityName = entityName;
setting.EntityId = entityId; setting.EntityId = entityId;
setting.SettingName = kvp.Key; setting.SettingName = kvp.Key;
setting.SettingValue = kvp.Value; setting.SettingValue = value;
setting.IsPublic = ispublic;
setting = await AddSettingAsync(setting); setting = await AddSettingAsync(setting);
} }
else else
{ {
if (setting.SettingValue != kvp.Value) if (setting.SettingValue != kvp.Value)
{ {
setting.SettingValue = kvp.Value; setting.SettingValue = value;
setting.IsPublic = ispublic;
setting = await UpdateSettingAsync(setting); setting = await UpdateSettingAsync(setting);
} }
} }
@ -163,13 +173,19 @@ namespace Oqtane.Services
} }
public Dictionary<string, string> SetSetting(Dictionary<string, string> settings, string settingName, string settingValue) public Dictionary<string, string> SetSetting(Dictionary<string, string> settings, string settingName, string settingValue)
{
return SetSetting(settings, settingName, settingValue, false);
}
public Dictionary<string, string> SetSetting(Dictionary<string, string> settings, string settingName, string settingValue, bool isPublic)
{ {
if (settings == null) if (settings == null)
{ {
settings = new Dictionary<string, string>(); settings = new Dictionary<string, string>();
} }
settingValue = (isPublic) ? "[Public]" + settingValue : settingValue;
if (settings.ContainsKey(settingName)) if (settings.ContainsKey(settingName))
{ {
settings[settingName] = settingValue; settings[settingName] = settingValue;
} }
else else
@ -178,5 +194,28 @@ namespace Oqtane.Services
} }
return settings; return settings;
} }
public Dictionary<string, string> MergeSettings(Dictionary<string, string> settings1, Dictionary<string, string> settings2)
{
if (settings1 == null)
{
settings1 = new Dictionary<string, string>();
}
if (settings2 != null)
{
foreach (var setting in settings2)
{
if (settings1.ContainsKey(setting.Key))
{
settings1[setting.Key] = setting.Value;
}
else
{
settings1.Add(setting.Key, setting.Value);
}
}
}
return settings1;
}
} }
} }

View File

@ -12,7 +12,16 @@
<button type="button" class="btn btn-primary" @onclick="LogoutUser">@Localizer["Logout"]</button> <button type="button" class="btn btn-primary" @onclick="LogoutUser">@Localizer["Logout"]</button>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
<button type="button" class="btn btn-primary" @onclick="LoginUser">@SharedLocalizer["Login"]</button> @if (ShowLogin)
{
<button type="button" class="btn btn-primary" @onclick="LoginUser">@SharedLocalizer["Login"]</button>
}
</NotAuthorized> </NotAuthorized>
</AuthorizeView> </AuthorizeView>
</span> </span>
@code
{
[Parameter]
public bool ShowLogin { get; set; }
}

View File

@ -13,9 +13,9 @@
<button type="button" class="btn btn-primary" @onclick="UpdateProfile">@context.User.Identity.Name</button> <button type="button" class="btn btn-primary" @onclick="UpdateProfile">@context.User.Identity.Name</button>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@if (PageState.Site.AllowRegistration) @if (ShowRegister && PageState.Site.AllowRegistration)
{ {
<button type="button" class="btn btn-primary" @onclick="RegisterUser">@Localizer["Register"]</button> <button type="button" class="btn btn-primary" @onclick="RegisterUser">@Localizer["Register"]</button>
} }
</NotAuthorized> </NotAuthorized>
</AuthorizeView> </AuthorizeView>
@ -23,6 +23,9 @@
@code { @code {
[Parameter]
public bool ShowRegister { get; set; }
private void RegisterUser() private void RegisterUser()
{ {
NavigationManager.NavigateTo(NavigateUrl("register")); NavigationManager.NavigateTo(NavigateUrl("register"));

View File

@ -6,7 +6,7 @@
<nav class="navbar navbar-expand-md navbar-dark bg-primary fixed-top"> <nav class="navbar navbar-expand-md navbar-dark bg-primary fixed-top">
<Logo /><Menu Orientation="Horizontal" /> <Logo /><Menu Orientation="Horizontal" />
<div class="controls ms-auto"> <div class="controls ms-auto">
<div class="controls-group"><UserProfile /> <Login /> <ControlPanel /></div> <div class="controls-group"><UserProfile ShowRegister="@_register" /> <Login ShowLogin="@_login" /> <ControlPanel /></div>
</div> </div>
</nav> </nav>
<div class="content"> <div class="content">
@ -117,13 +117,18 @@
new Resource { ResourceType = ResourceType.Script, Bundle = "Bootstrap", Url = "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js", Integrity = "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM", CrossOrigin = "anonymous" } new Resource { ResourceType = ResourceType.Script, Bundle = "Bootstrap", Url = "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js", Integrity = "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM", CrossOrigin = "anonymous" }
}; };
private bool _login = true;
private bool _register = true;
private bool _footer = false; private bool _footer = false;
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
try try
{ {
_footer = bool.Parse(SettingService.GetSetting(PageState.Page.Settings, GetType().Namespace + ":Footer", "false")); var settings = SettingService.MergeSettings(PageState.Site.Settings, PageState.Page.Settings);
_login = bool.Parse(SettingService.GetSetting(settings, GetType().Namespace + ":Login", "true"));
_register = bool.Parse(SettingService.GetSetting(settings, GetType().Namespace + ":Register", "true"));
_footer = bool.Parse(SettingService.GetSetting(settings, GetType().Namespace + ":Footer", "false"));
} }
catch catch
{ {

View File

@ -2,47 +2,149 @@
@inherits ModuleBase @inherits ModuleBase
@implements Oqtane.Interfaces.ISettingsControl @implements Oqtane.Interfaces.ISettingsControl
@inject ISettingService SettingService @inject ISettingService SettingService
@inject IStringLocalizer<ThemeSettings> Localizer
@inject IStringLocalizer<SharedResources> SharedLocalizer
@attribute [OqtaneIgnore] @attribute [OqtaneIgnore]
<div class="container"> <div class="container">
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="footer" ResourceKey="Footer" HelpText="Specify If A Footer Should Always Be Displayed In A Fixed Location At The Bottom Of The Browser Window">Display Fixed Footer?</Label> <Label Class="col-sm-3" For="scope" ResourceKey="Scope" HelpText="Specify if the settings are applicable to this page or the entire site.">Setting Scope:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<select id="footer" class="form-select" @bind="@_footer"> <select id="scope" class="form-select" value="@_scope" @onchange="(e => ScopeChanged(e))">
<option value="true">Yes</option> @if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin))
<option value="false">No</option> {
</select> <option value="site">@Localizer["Site"]</option>
</div> }
<option value="page">@Localizer["Page"]</option>
</select>
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="login" ResourceKey="Login" HelpText="Specify if a Login option should be displayed. Note that this option does not prevent the login page from being accessible via a direct url.">Show Login?</Label>
<div class="col-sm-9">
<select id="login" class="form-select" @bind="@_login">
<option value="-">&lt;@SharedLocalizer["Not Specified"]&gt;</option>
<option value="true">@SharedLocalizer["Yes"]</option>
<option value="false">@SharedLocalizer["No"]</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="register" ResourceKey="Register" HelpText="Specify if a Register option should be displayed. Note that this option is also dependent on the Allow Registration option in Site Settings.">Show Register?</Label>
<div class="col-sm-9">
<select id="register" class="form-select" @bind="@_register">
<option value="-">&lt;@SharedLocalizer["Not Specified"]&gt;</option>
<option value="true">@SharedLocalizer["Yes"]</option>
<option value="false">@SharedLocalizer["No"]</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="footer" ResourceKey="Footer" HelpText="Specify if a Footer pane should always be displayed in a fixed location at the bottom of the page">Display Fixed Footer?</Label>
<div class="col-sm-9">
<select id="footer" class="form-select" @bind="@_footer">
<option value="-">&lt;@SharedLocalizer["Not Specified"]&gt;</option>
<option value="true">@SharedLocalizer["Yes"]</option>
<option value="false">@SharedLocalizer["No"]</option>
</select>
</div>
</div>
</div>
@code {
private string _scope = "page";
private string _login = "-";
private string _register = "-";
private string _footer = "-";
@code { protected override async Task OnInitializedAsync()
private string _footer = "false"; {
try
protected override void OnInitialized()
{ {
try await LoadSettings();
{
_footer = SettingService.GetSetting(PageState.Page.Settings, GetType().Namespace + ":Footer", "false");
}
catch (Exception ex)
{
ModuleInstance.AddModuleMessage(ex.Message, MessageType.Error);
}
} }
catch (Exception ex)
public async Task UpdateSettings()
{ {
try await logger.LogError(ex, "Error Loading Settings {Error}", ex.Message);
{ AddModuleMessage("Error Loading Settings", MessageType.Error);
var settings = PageState.Page.Settings;
settings = SettingService.SetSetting(settings, GetType().Namespace + ":Footer", _footer);
await SettingService.UpdatePageSettingsAsync(settings, PageState.Page.PageId);
}
catch (Exception ex)
{
ModuleInstance.AddModuleMessage(ex.Message, MessageType.Error);
}
} }
} }
private async Task LoadSettings()
{
await Task.Yield();
Dictionary<string, string> settings;
if (_scope == "site")
{
settings = PageState.Site.Settings;
_login = SettingService.GetSetting(settings, GetType().Namespace + ":Login", "true");
_register = SettingService.GetSetting(settings, GetType().Namespace + ":Register", "true");
_footer = SettingService.GetSetting(settings, GetType().Namespace + ":Footer", "false");
}
else
{
settings = SettingService.MergeSettings(PageState.Site.Settings, PageState.Page.Settings);
_login = SettingService.GetSetting(settings, GetType().Namespace + ":Login", "-");
_register = SettingService.GetSetting(settings, GetType().Namespace + ":Register", "-");
_footer = SettingService.GetSetting(settings, GetType().Namespace + ":Footer", "-");
}
}
private async Task ScopeChanged(ChangeEventArgs eventArgs)
{
try
{
_scope = (string)eventArgs.Value;
await LoadSettings();
StateHasChanged();
}
catch (Exception ex)
{
await logger.LogError(ex, "Error Loading Settings {Error}", ex.Message);
AddModuleMessage("Error Loading Settings", MessageType.Error);
}
}
public async Task UpdateSettings()
{
try
{
Dictionary<string, string> settings;
if (_scope == "site")
{
settings = PageState.Site.Settings;
}
else
{
settings = PageState.Page.Settings;
}
if (_login != "-")
{
settings = SettingService.SetSetting(settings, GetType().Namespace + ":Login", _login, true);
}
if (_register != "-")
{
settings = SettingService.SetSetting(settings, GetType().Namespace + ":Register", _register, true);
}
if (_footer != "-")
{
settings = SettingService.SetSetting(settings, GetType().Namespace + ":Footer", _footer, true);
}
if (_scope == "site")
{
await SettingService.UpdateSiteSettingsAsync(settings, PageState.Site.SiteId);
}
else
{
await SettingService.UpdatePageSettingsAsync(settings, PageState.Page.PageId);
}
}
catch (Exception ex)
{
await logger.LogError(ex, "Error Saving Settings {Error}", ex.Message);
AddModuleMessage("Error Saving Settings", MessageType.Error);
}
}
}

View File

@ -104,9 +104,9 @@ namespace Oqtane.Themes
return Utilities.ContentUrl(PageState.Alias, fileid, asAttachment); return Utilities.ContentUrl(PageState.Alias, fileid, asAttachment);
} }
public string ImageUrl(int fileid, string size, string mode) public string ImageUrl(int fileid, int width, int height, string mode)
{ {
return Utilities.ImageUrl(PageState.Alias, fileid, size, mode); return Utilities.ImageUrl(PageState.Alias, fileid, width, height, mode);
} }
} }
} }

View File

@ -188,9 +188,9 @@ namespace Oqtane.Controllers
} }
} }
// GET api/<controller>/upload?url=x&folderid=y // GET api/<controller>/upload?url=x&folderid=y&name=z
[HttpGet("upload")] [HttpGet("upload")]
public Models.File UploadFile(string url, string folderid) public Models.File UploadFile(string url, string folderid, string name)
{ {
Models.File file = null; Models.File file = null;
@ -206,16 +206,19 @@ namespace Oqtane.Controllers
string folderPath = _folders.GetFolderPath(folder); string folderPath = _folders.GetFolderPath(folder);
CreateDirectory(folderPath); CreateDirectory(folderPath);
string filename = url.Substring(url.LastIndexOf("/", StringComparison.Ordinal) + 1); if (string.IsNullOrEmpty(name))
{
name = url.Substring(url.LastIndexOf("/", StringComparison.Ordinal) + 1);
}
// check for allowable file extensions // check for allowable file extensions
if (!Constants.UploadableFiles.Split(',').Contains(Path.GetExtension(filename).ToLower().Replace(".", ""))) if (!Constants.UploadableFiles.Split(',').Contains(Path.GetExtension(name).ToLower().Replace(".", "")))
{ {
_logger.Log(LogLevel.Error, this, LogFunction.Create, "File Could Not Be Downloaded From Url Due To Its File Extension {Url}", url); _logger.Log(LogLevel.Error, this, LogFunction.Create, "File Could Not Be Downloaded From Url Due To Its File Extension {Url}", url);
HttpContext.Response.StatusCode = (int)HttpStatusCode.Conflict; HttpContext.Response.StatusCode = (int)HttpStatusCode.Conflict;
return file; return file;
} }
if (!filename.IsPathOrFileValid()) if (!name.IsPathOrFileValid())
{ {
_logger.Log(LogLevel.Error, this, LogFunction.Create, $"File Could Not Be Downloaded From Url Due To Its File Name Not Allowed {url}"); _logger.Log(LogLevel.Error, this, LogFunction.Create, $"File Could Not Be Downloaded From Url Due To Its File Name Not Allowed {url}");
HttpContext.Response.StatusCode = (int)HttpStatusCode.Conflict; HttpContext.Response.StatusCode = (int)HttpStatusCode.Conflict;
@ -225,7 +228,7 @@ namespace Oqtane.Controllers
try try
{ {
var client = new WebClient(); var client = new WebClient();
string targetPath = Path.Combine(folderPath, filename); string targetPath = Path.Combine(folderPath, name);
// remove file if it already exists // remove file if it already exists
if (System.IO.File.Exists(targetPath)) if (System.IO.File.Exists(targetPath))
{ {
@ -233,15 +236,15 @@ namespace Oqtane.Controllers
} }
client.DownloadFile(url, targetPath); client.DownloadFile(url, targetPath);
file = CreateFile(filename, folder.FolderId, targetPath); file = CreateFile(name, folder.FolderId, targetPath);
if (file != null) if (file != null)
{ {
file = _files.AddFile(file); file = _files.AddFile(file);
} }
} }
catch catch (Exception ex)
{ {
_logger.Log(LogLevel.Error, this, LogFunction.Create, "File Could Not Be Downloaded From Url {Url}", url); _logger.Log(LogLevel.Error, this, LogFunction.Create, "File Could Not Be Downloaded From Url {Url} {Error}", url, ex.Message);
} }
} }
else else
@ -494,8 +497,8 @@ namespace Oqtane.Controllers
return System.IO.File.Exists(errorPath) ? PhysicalFile(errorPath, MimeUtilities.GetMimeType(errorPath)) : null; return System.IO.File.Exists(errorPath) ? PhysicalFile(errorPath, MimeUtilities.GetMimeType(errorPath)) : null;
} }
[HttpGet("image/{id}/{size}/{mode?}")] [HttpGet("image/{id}/{width}/{height}/{mode?}")]
public IActionResult GetImage(int id, string size, string mode) public IActionResult GetImage(int id, int width, int height, string mode)
{ {
var file = _files.GetFile(id); var file = _files.GetFile(id);
if (file != null && file.Folder.SiteId == _alias.SiteId && _userPermissions.IsAuthorized(User, PermissionNames.View, file.Folder.Permissions)) if (file != null && file.Folder.SiteId == _alias.SiteId && _userPermissions.IsAuthorized(User, PermissionNames.View, file.Folder.Permissions))
@ -505,27 +508,31 @@ namespace Oqtane.Controllers
var filepath = _files.GetFilePath(file); var filepath = _files.GetFilePath(file);
if (System.IO.File.Exists(filepath)) if (System.IO.File.Exists(filepath))
{ {
size = size.ToLower();
mode = (string.IsNullOrEmpty(mode)) ? "crop" : mode; mode = (string.IsNullOrEmpty(mode)) ? "crop" : mode;
if ((_userPermissions.IsAuthorized(User, PermissionNames.Edit, file.Folder.Permissions) ||
size.Contains("x") && !string.IsNullOrEmpty(file.Folder.ImageSizes) && file.Folder.ImageSizes.ToLower().Split(",").Contains(size)) string imagepath = filepath.Replace(Path.GetExtension(filepath), "." + width.ToString() + "x" + height.ToString() + "." + mode.ToLower() + ".png");
&& Enum.TryParse(mode, true, out ResizeMode resizemode)) if (!System.IO.File.Exists(imagepath))
{ {
var imagepath = CreateImage(filepath, size, resizemode.ToString()); if ((_userPermissions.IsAuthorized(User, PermissionNames.Edit, file.Folder.Permissions) ||
if (!string.IsNullOrEmpty(imagepath)) !string.IsNullOrEmpty(file.Folder.ImageSizes) && file.Folder.ImageSizes.ToLower().Split(",").Contains(width.ToString() + "x" + height.ToString()))
&& Enum.TryParse(mode, true, out ResizeMode resizemode))
{ {
return PhysicalFile(imagepath, file.GetMimeType()); imagepath = CreateImage(filepath, width, height, resizemode.ToString(), imagepath);
} }
else else
{ {
_logger.Log(LogLevel.Error, this, LogFunction.Create, "Error Creating Image For File {File} {Size}", file, size); _logger.Log(LogLevel.Error, this, LogFunction.Security, "Invalid Image Size For Folder Or Invalid Mode Specification {Folder} {Width} {Height} {Mode}", file.Folder, width, height, mode);
HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden;
} }
} }
if (!string.IsNullOrEmpty(imagepath))
{
return PhysicalFile(imagepath, file.GetMimeType());
}
else else
{ {
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Invalid Image Size For Folder Or Invalid Mode Specification {Folder} {Size} {Mode}", file.Folder, size, mode); _logger.Log(LogLevel.Error, this, LogFunction.Create, "Error Displaying Image For File {File} {Width} {Height}", file, width, height);
HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound;
} }
} }
else else
@ -550,38 +557,31 @@ namespace Oqtane.Controllers
return System.IO.File.Exists(errorPath) ? PhysicalFile(errorPath, MimeUtilities.GetMimeType(errorPath)) : null; return System.IO.File.Exists(errorPath) ? PhysicalFile(errorPath, MimeUtilities.GetMimeType(errorPath)) : null;
} }
private string CreateImage(string filepath, string size, string mode) private string CreateImage(string filepath, int width, int height, string mode, string imagepath)
{ {
string imagepath = filepath.Replace(Path.GetExtension(filepath), "." + size + "." + mode.ToLower() + ".png"); try
if (!System.IO.File.Exists(imagepath))
{ {
try FileStream stream = new FileStream(filepath, FileMode.Open, FileAccess.Read);
using (Image image = Image.Load(stream))
{ {
FileStream stream = new FileStream(filepath, FileMode.Open, FileAccess.Read); Enum.TryParse(mode, true, out ResizeMode resizemode);
using (Image image = Image.Load(stream))
{
var parts = size.Split('x');
int width = (!string.IsNullOrEmpty(parts[0])) ? int.Parse(parts[0]) : 0;
int height = (!string.IsNullOrEmpty(parts[1])) ? int.Parse(parts[1]) : 0;
Enum.TryParse(mode, true, out ResizeMode resizemode);
image.Mutate(x => image.Mutate(x =>
x.Resize(new ResizeOptions x.Resize(new ResizeOptions
{ {
Size = new Size(width, height), Size = new Size(width, height),
Mode = resizemode Mode = resizemode
}) })
.BackgroundColor(new Rgba32(255, 255, 255, 0))); .BackgroundColor(new Rgba32(255, 255, 255, 0)));
image.Save(imagepath, new PngEncoder()); image.Save(imagepath, new PngEncoder());
}
stream.Close();
}
catch // error creating image
{
imagepath = "";
} }
stream.Close();
}
catch (Exception ex)
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Error Creating Image For File {FilePath} {Width} {Height} {Mode} {Error}", filepath, width, height, mode, ex.Message);
imagepath = "";
} }
return imagepath; return imagepath;

View File

@ -39,6 +39,10 @@ namespace Oqtane.Controllers
if (IsAuthorized(entityname, entityid, PermissionNames.View)) if (IsAuthorized(entityname, entityid, PermissionNames.View))
{ {
settings = _settings.GetSettings(entityname, entityid).ToList(); settings = _settings.GetSettings(entityname, entityid).ToList();
if (entityname == EntityNames.Site && !User.IsInRole(RoleNames.Admin))
{
settings = settings.Where(item => item.IsPublic).ToList();
}
} }
else else
{ {
@ -55,6 +59,10 @@ namespace Oqtane.Controllers
Setting setting = _settings.GetSetting(id); Setting setting = _settings.GetSetting(id);
if (IsAuthorized(setting.EntityName, setting.EntityId, PermissionNames.View)) if (IsAuthorized(setting.EntityName, setting.EntityId, PermissionNames.View))
{ {
if (setting.EntityName == EntityNames.Site && !User.IsInRole(RoleNames.Admin) && !setting.IsPublic)
{
setting = null;
}
return setting; return setting;
} }
else else
@ -72,10 +80,7 @@ namespace Oqtane.Controllers
if (ModelState.IsValid && IsAuthorized(setting.EntityName, setting.EntityId, PermissionNames.Edit)) if (ModelState.IsValid && IsAuthorized(setting.EntityName, setting.EntityId, PermissionNames.Edit))
{ {
setting = _settings.AddSetting(setting); setting = _settings.AddSetting(setting);
if (setting.EntityName == EntityNames.Module) AddSyncEvent(setting.EntityName);
{
_syncManager.AddSyncEvent(_alias.TenantId, EntityNames.Site, _alias.SiteId);
}
_logger.Log(LogLevel.Information, this, LogFunction.Create, "Setting Added {Setting}", setting); _logger.Log(LogLevel.Information, this, LogFunction.Create, "Setting Added {Setting}", setting);
} }
else else
@ -94,10 +99,7 @@ namespace Oqtane.Controllers
if (ModelState.IsValid && IsAuthorized(setting.EntityName, setting.EntityId, PermissionNames.Edit)) if (ModelState.IsValid && IsAuthorized(setting.EntityName, setting.EntityId, PermissionNames.Edit))
{ {
setting = _settings.UpdateSetting(setting); setting = _settings.UpdateSetting(setting);
if (setting.EntityName == EntityNames.Module) AddSyncEvent(setting.EntityName);
{
_syncManager.AddSyncEvent(_alias.TenantId, EntityNames.Site, _alias.SiteId);
}
_logger.Log(LogLevel.Information, this, LogFunction.Update, "Setting Updated {Setting}", setting); _logger.Log(LogLevel.Information, this, LogFunction.Update, "Setting Updated {Setting}", setting);
} }
else else
@ -117,10 +119,7 @@ namespace Oqtane.Controllers
if (IsAuthorized(setting.EntityName, setting.EntityId, PermissionNames.Edit)) if (IsAuthorized(setting.EntityName, setting.EntityId, PermissionNames.Edit))
{ {
_settings.DeleteSetting(id); _settings.DeleteSetting(id);
if (setting.EntityName == EntityNames.Module) AddSyncEvent(setting.EntityName);
{
_syncManager.AddSyncEvent(_alias.TenantId, EntityNames.Site, _alias.SiteId);
}
_logger.Log(LogLevel.Information, this, LogFunction.Delete, "Setting Deleted {Setting}", setting); _logger.Log(LogLevel.Information, this, LogFunction.Delete, "Setting Deleted {Setting}", setting);
} }
else else
@ -144,7 +143,14 @@ namespace Oqtane.Controllers
authorized = User.IsInRole(RoleNames.Host); authorized = User.IsInRole(RoleNames.Host);
break; break;
case EntityNames.Site: case EntityNames.Site:
authorized = User.IsInRole(RoleNames.Admin); if (permissionName == PermissionNames.Edit)
{
authorized = User.IsInRole(RoleNames.Admin);
}
else
{
authorized = true;
}
break; break;
case EntityNames.Page: case EntityNames.Page:
case EntityNames.Module: case EntityNames.Module:
@ -161,5 +167,17 @@ namespace Oqtane.Controllers
} }
return authorized; return authorized;
} }
private void AddSyncEvent(string EntityName)
{
switch (EntityName)
{
case EntityNames.Module:
case EntityNames.Page:
case EntityNames.Site:
_syncManager.AddSyncEvent(_alias.TenantId, EntityNames.Site, _alias.SiteId);
break;
}
}
} }
} }

View File

@ -15,13 +15,15 @@ namespace Oqtane.Controllers
public class SiteController : Controller public class SiteController : Controller
{ {
private readonly ISiteRepository _sites; private readonly ISiteRepository _sites;
private readonly ISettingRepository _settings;
private readonly ISyncManager _syncManager; private readonly ISyncManager _syncManager;
private readonly ILogManager _logger; private readonly ILogManager _logger;
private readonly Alias _alias; private readonly Alias _alias;
public SiteController(ISiteRepository sites, ITenantManager tenantManager, ISyncManager syncManager, ILogManager logger) public SiteController(ISiteRepository sites, ISettingRepository settings, ITenantManager tenantManager, ISyncManager syncManager, ILogManager logger)
{ {
_sites = sites; _sites = sites;
_settings = settings;
_syncManager = syncManager; _syncManager = syncManager;
_logger = logger; _logger = logger;
_alias = tenantManager.GetAlias(); _alias = tenantManager.GetAlias();
@ -42,6 +44,12 @@ namespace Oqtane.Controllers
var site = _sites.GetSite(id); var site = _sites.GetSite(id);
if (site.SiteId == _alias.SiteId) if (site.SiteId == _alias.SiteId)
{ {
var settings = _settings.GetSettings(EntityNames.Site, site.SiteId);
if (!User.IsInRole(RoleNames.Admin))
{
settings = settings.Where(item => item.IsPublic);
}
site.Settings = settings.ToDictionary(setting => setting.SettingName, setting => setting.SettingValue);
return site; return site;
} }
else else

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Oqtane.Databases.Interfaces;
using Oqtane.Migrations.EntityBuilders;
using Oqtane.Repository;
using Oqtane.Shared;
namespace Oqtane.Migrations.Tenant
{
[DbContext(typeof(TenantDBContext))]
[Migration("Tenant.02.03.00.02")]
public class AddSettingIsPublic : MultiDatabaseMigration
{
public AddSettingIsPublic(IDatabase database) : base(database)
{
}
protected override void Up(MigrationBuilder migrationBuilder)
{
var settingEntityBuilder = new SettingEntityBuilder(migrationBuilder, ActiveDatabase);
settingEntityBuilder.AddBooleanColumn("IsPublic", true);
settingEntityBuilder.UpdateColumn("IsPublic", "0");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
var settingEntityBuilder = new SettingEntityBuilder(migrationBuilder, ActiveDatabase);
settingEntityBuilder.DropColumn("IsPublic");
}
}
}

View File

@ -104,10 +104,13 @@ namespace Oqtane.Models
/// </summary> /// </summary>
[NotMapped] [NotMapped]
public List<Resource> Resources { get; set; } public List<Resource> Resources { get; set; }
[NotMapped] [NotMapped]
public string Permissions { get; set; } public string Permissions { get; set; }
[NotMapped] [NotMapped]
public Dictionary<string, string> Settings { get; set; } public Dictionary<string, string> Settings { get; set; }
[NotMapped] [NotMapped]
public int Level { get; set; } public int Level { get; set; }

View File

@ -32,6 +32,11 @@ namespace Oqtane.Models
/// </summary> /// </summary>
public string SettingValue { get; set; } public string SettingValue { get; set; }
/// <summary>
/// Indicates if this setting is publicly available - only applicable to Site Settings as other entities have more granular permissions
/// </summary>
public bool IsPublic { get; set; }
#region IAuditable Properties #region IAuditable Properties
/// <inheritdoc/> /// <inheritdoc/>

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
namespace Oqtane.Models namespace Oqtane.Models
@ -81,6 +82,9 @@ namespace Oqtane.Models
[NotMapped] [NotMapped]
public string SiteTemplateType { get; set; } public string SiteTemplateType { get; set; }
[NotMapped]
public Dictionary<string, string> Settings { get; set; }
[NotMapped] [NotMapped]
[Obsolete("This property is deprecated.", false)] [Obsolete("This property is deprecated.", false)]
public string DefaultLayoutType { get; set; } public string DefaultLayoutType { get; set; }

View File

@ -112,10 +112,10 @@ namespace Oqtane.Shared
return $"{aliasUrl}{Constants.ContentUrl}{fileId}{method}"; return $"{aliasUrl}{Constants.ContentUrl}{fileId}{method}";
} }
public static string ImageUrl(Alias alias, int fileId, string size, string mode) public static string ImageUrl(Alias alias, int fileId, int width, int height, string mode)
{ {
var aliasUrl = (alias != null && !string.IsNullOrEmpty(alias.Path)) ? "/" + alias.Path : ""; var aliasUrl = (alias != null && !string.IsNullOrEmpty(alias.Path)) ? "/" + alias.Path : "";
return $"{aliasUrl}{Constants.ImageUrl}{fileId}/{size}/{mode}"; return $"{aliasUrl}{Constants.ImageUrl}{fileId}/{width}/{height}/{mode}";
} }
public static string TenantUrl(Alias alias, string url) public static string TenantUrl(Alias alias, string url)