Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added import functionality for redirects module #284

Merged
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
using Umbraco.Cms.Web.Common.ApplicationBuilder;
using Umbraco.Extensions;
using SeoToolkit.Umbraco.Common.Core.Constants;
using SeoToolkit.Umbraco.Common.Core.Extensions;
using SeoToolkit.Umbraco.Common.Core.Services.SettingsService;
using SeoToolkit.Umbraco.Redirects.Core.Components;
using SeoToolkit.Umbraco.Redirects.Core.Config;
using SeoToolkit.Umbraco.Redirects.Core.Config.Models;
using SeoToolkit.Umbraco.Redirects.Core.Controllers;
using SeoToolkit.Umbraco.Redirects.Core.Helpers;
using SeoToolkit.Umbraco.Redirects.Core.Interfaces;
using SeoToolkit.Umbraco.Redirects.Core.Middleware;
using SeoToolkit.Umbraco.Redirects.Core.Repositories;
Expand Down Expand Up @@ -42,11 +42,12 @@ public void Compose(IUmbracoBuilder builder)
{
builder.Trees().RemoveTreeController<RedirectsTreeController>();
}

builder.Components().Append<EnableModuleComponent>();

builder.Services.AddUnique<IRedirectsRepository, RedirectsRepository>();
builder.Services.AddUnique<IRedirectsService, RedirectsService>();
builder.Services.AddTransient<RedirectsImportHelper>();

if (!disabledModules.Contains(DisabledModuleConstant.Middleware))
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace SeoToolkit.Umbraco.Redirects.Core.Constants;

public class ImportConstants
{
public const string SessionAlias = "uploadedImportRedirectsFile";
public const string SessionFileTypeAlias = "uploadedFileType";
public const string SessionDomainId = "selectedDomain";
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
using System.Linq;
using System;
using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SeoToolkit.Umbraco.Redirects.Core.Constants;
using SeoToolkit.Umbraco.Redirects.Core.Enumerators;
using SeoToolkit.Umbraco.Redirects.Core.Helpers;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
Expand All @@ -22,16 +27,21 @@ public class RedirectsController : UmbracoAuthorizedApiController
private readonly IUmbracoContextFactory _umbracoContextFactory;
private readonly ILocalizationService _localizationService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly RedirectsImportHelper _redirectsImportHelper;
private readonly IHttpContextAccessor _httpContextAccessor;


public RedirectsController(IRedirectsService redirectsService,
IUmbracoContextFactory umbracoContextFactory,
ILocalizationService localizationService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
IBackOfficeSecurityAccessor backOfficeSecurityAccessor, RedirectsImportHelper redirectsImportHelper, IHttpContextAccessor httpContextAccessor)
{
_redirectsService = redirectsService;
_umbracoContextFactory = umbracoContextFactory;
_localizationService = localizationService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_redirectsImportHelper = redirectsImportHelper;
_httpContextAccessor = httpContextAccessor;
}

[HttpPost]
Expand Down Expand Up @@ -134,5 +144,67 @@ public IActionResult Delete(DeleteRedirectsPostModel postModel)
_redirectsService.Delete(postModel.Ids);
return GetAll(1, 20);
}

[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public IActionResult Validate(ImportRedirectsFileExtension fileExtension, string domain, IFormFile file)
{
if (file.Length == 0)
{
return BadRequest("Please select a file");
}

using var memoryStream = new MemoryStream();
file.CopyTo(memoryStream);

var result = _redirectsImportHelper.Validate(fileExtension, memoryStream, domain);
if (result.Success)
{
if (_httpContextAccessor.HttpContext is null)
Ambertvu marked this conversation as resolved.
Show resolved Hide resolved
{
return BadRequest("Could not get context, please try again");
}

// Storing the file contents in session for later import
_httpContextAccessor.HttpContext.Session.Set(ImportConstants.SessionAlias, memoryStream.ToArray());
_httpContextAccessor.HttpContext.Session.SetString(ImportConstants.SessionFileTypeAlias, fileExtension.ToString());
_httpContextAccessor.HttpContext.Session.SetString(ImportConstants.SessionDomainId, domain);

return Ok();
}

return UnprocessableEntity(!string.IsNullOrWhiteSpace(result.Status) ? result.Status : "Something went wrong during the validation");
}

[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public IActionResult Import()
{
var fileContent = HttpContext.Session.Get(ImportConstants.SessionAlias);
var fileExtensionString = HttpContext.Session.GetString(ImportConstants.SessionFileTypeAlias);
var domain= HttpContext.Session.GetString(ImportConstants.SessionDomainId);

if (fileContent == null || fileExtensionString == null || domain == null)
{
return BadRequest("Something went wrong during import, please try again");
}

if (!Enum.TryParse(fileExtensionString, out ImportRedirectsFileExtension fileExtension))
{
return UnprocessableEntity("Invalid file extension.");
}

using var memoryStream = new MemoryStream(fileContent);
var result = _redirectsImportHelper.Import(fileExtension, memoryStream, domain);
if (result.Success)
{
return Ok();
}

return UnprocessableEntity(!string.IsNullOrWhiteSpace(result.Status) ? result.Status : "Something went wrong during the import");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace SeoToolkit.Umbraco.Redirects.Core.Enumerators;

public enum ImportRedirectsFileExtension
{
Csv,
Excel
}
248 changes: 248 additions & 0 deletions src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using ExcelDataReader;
using Microsoft.VisualBasic.FileIO;
using SeoToolkit.Umbraco.Redirects.Core.Enumerators;
using SeoToolkit.Umbraco.Redirects.Core.Interfaces;
using SeoToolkit.Umbraco.Redirects.Core.Models.Business;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;

namespace SeoToolkit.Umbraco.Redirects.Core.Helpers;

public class RedirectsImportHelper
{
private Domain _selectedDomain;
private readonly IRedirectsService _redirectsService;
private readonly IUmbracoContextFactory _umbracoContextFactory;

public RedirectsImportHelper(IRedirectsService redirectsService, IUmbracoContextFactory umbracoContextFactory)
{
_redirectsService = redirectsService;
_umbracoContextFactory = umbracoContextFactory;
}

public Attempt<Dictionary<string,string>?, string> Validate(ImportRedirectsFileExtension fileExtension, MemoryStream memoryStream, string domain)
{
SetDomain(domain);
Attempt<Dictionary<string,string>, string> validationResult;
switch (fileExtension)
{
case ImportRedirectsFileExtension.Csv:
validationResult = ValidateCsv(memoryStream);
break;
case ImportRedirectsFileExtension.Excel:
validationResult = ValidateExcel(memoryStream);
break;
default:
return Attempt<Dictionary<string,string>?, string>.Fail("Invalid filetype, you may only use .csv or .xls", result: null);
}

if (validationResult.Success)
{
return Attempt<Dictionary<string,string>?, string>.Succeed(string.Empty, validationResult.Result);
}

return validationResult;
}

public Attempt<Dictionary<string,string>?, string> Import(ImportRedirectsFileExtension fileExtension, MemoryStream memoryStream, string domain)
{
SetDomain(domain);
var validation = Validate(fileExtension, memoryStream, domain);
if (validation is { Success: true, Result: not null } && validation.Result.Count != 0)
{
foreach (var entry in validation.Result)
{
SaveRedirect(entry);
}
}

return validation;
}

private bool UrlExists(string oldUrl)
Ambertvu marked this conversation as resolved.
Show resolved Hide resolved
{
var existingRedirects = _redirectsService.GetAll(1, 10, null, null, oldUrl.TrimEnd('/'));
if (existingRedirects.TotalItems > 0 && existingRedirects.Items is not null)
{
if (existingRedirects.Items.Count(x => x.Domain is null || x.Domain.Id == 0) > 0)
{
//url exists without any domain set
return true;
}
if (existingRedirects.Items.Count(x => x.Domain == _selectedDomain) > 0)
{
//url exists with specific domain set
return true;
}
}
return false;
}

private Attempt<Dictionary<string,string>?, string> ValidateCsv(Stream fileStream)
{
//This currently assumes no header, and only 2 columns, from and to url
fileStream.Position = 0;
using (var reader = new StreamReader(fileStream))
using (var parser = new TextFieldParser(reader))
{
parser.TextFieldType = FieldType.Delimited;
parser.SetDelimiters(",");
var parsedData = new Dictionary<string,string>();

while (!parser.EndOfData)
{
var fields = parser.ReadFields();

if (fields?.Length != 2)
{
return Attempt<Dictionary<string,string>?, string>.Fail($"Validation Fail: only 2 columns allowed on line {parser.LineNumber}", result: null);
}
var fromUrl = CleanFromUrl(fields[0]);
var toUrl = Uri.IsWellFormedUriString(fields[1], UriKind.Absolute) ?
fields[1] :
fields[1].EnsureEndsWith("/").ToLower();

if(!string.IsNullOrWhiteSpace(fromUrl) && !string.IsNullOrWhiteSpace(toUrl)){

if (!Uri.IsWellFormedUriString(fromUrl, UriKind.Relative))
{
return Attempt<Dictionary<string,string>?, string>.Fail($"line {parser.LineNumber}", result: null);
}
if(UrlExists(fromUrl))
{
return Attempt<Dictionary<string,string>?, string>.Fail($"Redirect already exists for 'from' URL: {fromUrl} validation aborted.", result: null);
}
parsedData.Add(fromUrl, toUrl);

}
else
{
return Attempt<Dictionary<string,string>?, string>.Fail($"line {parser.LineNumber}", result: null);
}
}
return Attempt<Dictionary<string,string>?, string>.Succeed(string.Empty, parsedData);
}


}
private Attempt<Dictionary<string,string>?, string> ValidateExcel(Stream fileStream)
{
fileStream.Position = 0;
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

try
{
using var reader = ExcelReaderFactory.CreateReader(fileStream);
var result = reader.AsDataSet();
var dataTable = result.Tables[0];

var parsedData = new Dictionary<string, string>();

for (var i = 0; i < dataTable.Rows.Count; i++)
{
var row = dataTable.Rows[i];
if (row.ItemArray.Length != 2)
{
return Attempt<Dictionary<string, string>?, string>.Fail($"only 2 columns allowed on row {i + 1}");
}

var fromUrl = CleanFromUrl(row[0].ToString());
var toUrl = Uri.IsWellFormedUriString(row[1].ToString(), UriKind.Absolute)
? row[1].ToString()
: row[1].ToString()?.EnsureEndsWith("/").ToLower();

if (!string.IsNullOrWhiteSpace(fromUrl) && !string.IsNullOrWhiteSpace(toUrl))
{
if (!Uri.IsWellFormedUriString(fromUrl, UriKind.Relative))
{
return Attempt<Dictionary<string, string>?, string>.Fail($"row {i + 1}", result: null);
}

if (UrlExists(fromUrl))
{
return Attempt<Dictionary<string, string>?, string>.Fail(
$"Redirect already exists for 'from' URL: {fromUrl} validation aborted.");
}

parsedData.Add(fromUrl, toUrl);
}
else
{
return Attempt<Dictionary<string, string>?, string>.Fail($"row {i + 1}");
}
}

return Attempt<Dictionary<string, string>?, string>.Succeed(string.Empty, parsedData);
}
catch
{
return Attempt<Dictionary<string, string>?, string>.Fail("Invalid file type");
}
}

private void SetDomain(string domain)
{
var parseSuccess = int.TryParse(domain, out var domainId);
if (!parseSuccess)
{
domainId = 0;
}

using var ctx = _umbracoContextFactory.EnsureUmbracoContext();
var foundDomain = ctx.UmbracoContext.Domains?.GetAll(false).FirstOrDefault(it => it.Id == domainId);
if (foundDomain is null)
{
return;
}

_selectedDomain = foundDomain;
}

private static string CleanFromUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
{
return string.Empty;
}
var urlParts = url.ToLowerInvariant().Split('?');
if (urlParts.Length == 0)
{
return string.Empty;
}
var fromUrl = urlParts[0].TrimEnd('/');
if (urlParts.Length > 1)
{
fromUrl = $"{fromUrl}?{string.Join("?", urlParts.Skip(1))}";
}

fromUrl = fromUrl.EnsureStartsWith("/");

return fromUrl;
}

private void SaveRedirect(KeyValuePair<string, string> entry)
{
var redirect = new Redirect
{
Domain = _selectedDomain,
CustomDomain = null,
Id = 0,
IsEnabled = true,
IsRegex = false,
NewNodeCulture = null,
NewNode = null,
NewUrl = entry.Value,
OldUrl = entry.Key,
RedirectCode = 301
};

_redirectsService.Save(redirect);
}
}
Loading