From 212464e1cc0ec8b0b7a93e449c011f8ee6f7e77d Mon Sep 17 00:00:00 2001 From: Ambert van Unen Date: Tue, 6 Aug 2024 12:16:10 +0200 Subject: [PATCH 1/6] Draft --- .../Constants/ImportConstants.cs | 8 + .../Controllers/RedirectsController.cs | 61 ++++++- .../ImportRedirectsFileExtension.cs | 7 + .../Helpers/RedirectsImportHelper.cs | 157 ++++++++++++++++++ .../SeoToolkit.Umbraco.Redirects.Core.csproj | 5 + 5 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 src/SeoToolkit.Umbraco.Redirects.Core/Constants/ImportConstants.cs create mode 100644 src/SeoToolkit.Umbraco.Redirects.Core/Enumerators/ImportRedirectsFileExtension.cs create mode 100644 src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Constants/ImportConstants.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Constants/ImportConstants.cs new file mode 100644 index 00000000..1a1e0bb0 --- /dev/null +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Constants/ImportConstants.cs @@ -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"; +} \ No newline at end of file diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs index 1595a2a5..6579c0bd 100644 --- a/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs @@ -1,6 +1,12 @@ -using System.Linq; +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +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 Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; @@ -134,5 +140,58 @@ public IActionResult Delete(DeleteRedirectsPostModel postModel) _redirectsService.Delete(postModel.Ids); return GetAll(1, 20); } + + [HttpPost] + public IActionResult ValidateRedirects(ImportRedirectsFileExtension fileExtension, string domain) + { + var files = HttpContext.Request.Form.Files; + if (files.Count != 1 || files[0].Length == 0) + { + return BadRequest(new { isValid = false, Error = "Please select a file"}); + } + + var file = HttpContext.Request.Form.Files[0]; + using var memoryStream = new MemoryStream(); + file.CopyTo(memoryStream); + + return Ok(Validate(fileExtension, memoryStream, domain, false)); + } + + private IActionResult Validate(ImportRedirectsFileExtension fileExtension, MemoryStream memoryStream, + string domain, bool importFile) + { + HttpResponseMessage validationResult; + switch (fileExtension) + { + case ImportRedirectsFileExtension.Csv: + validationResult = redirectsImportHelper.ImportCsv(memoryStream, importFile, domain); + break; + case ImportRedirectsFileExtension.Excel: + validationResult = redirectsImportHelper.ImportExcel(memoryStream, importFile, domain); + break; + default: + return BadRequest("Invalid filetype, you may only use .csv or .xls"); + } + } + + public IActionResult ImportRedirects() + { + 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 BadRequest("Invalid file extension."); + } + + using var memoryStream = new MemoryStream(fileContent); + return Ok(Validate(fileExtension, memoryStream, domain,true)); + } } } diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Enumerators/ImportRedirectsFileExtension.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Enumerators/ImportRedirectsFileExtension.cs new file mode 100644 index 00000000..1eac99af --- /dev/null +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Enumerators/ImportRedirectsFileExtension.cs @@ -0,0 +1,7 @@ +namespace SeoToolkit.Umbraco.Redirects.Core.Enumerators; + +public enum ImportRedirectsFileExtension +{ + Csv, + Excel +} diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs new file mode 100644 index 00000000..b28f46e3 --- /dev/null +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs @@ -0,0 +1,157 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using Microsoft.VisualBasic.FileIO; +using SeoToolkit.Umbraco.Redirects.Core.Interfaces; +using SeoToolkit.Umbraco.Redirects.Core.Models.Business; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Web; + +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 HttpResponseMessage ImportCsv(Stream fileStream, bool importFile, string domain) + { + SetDomain(domain); + //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(); + + while (!parser.EndOfData) + { + var fields = parser.ReadFields(); + if (fields?.Length != 2) + { + return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"Validation failed: only 2 columns allowed on line {parser.LineNumber}" }; + } + + if(!string.IsNullOrWhiteSpace(fields[0]) && !string.IsNullOrWhiteSpace(fields[1])){ + + if(UrlExists(fields[0])) + { + return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"Redirect already exists for 'from' URL: {fields[0]} validation aborted." }; + } + parsedData.Add(fields[0], fields[1]); + + } + else + { + return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"line {parser.LineNumber}" }; + } + } + + if (importFile) + { + foreach (var entry in parsedData) + { + this.SaveRedirect(entry); + } + } + } + + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK }; + } + + private bool UrlExists(string oldUrl) + { + var existingRedirects = _redirectsService.GetAll(1, 10, null, null, oldUrl.TrimEnd('/')); + return existingRedirects.TotalItems > 0; + } + + public HttpResponseMessage ImportExcel(Stream fileStream, bool importFile, string domain) + { + SetDomain(domain); + fileStream.Position = 0; + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + + using var reader = ExcelReaderFactory.CreateReader(fileStream); + var result = reader.AsDataSet(); + var dataTable = result.Tables[0]; + + var parsedData = new Dictionary(); + + for (var i = 0; i < dataTable.Rows.Count; i++) + { + var row = dataTable.Rows[i]; + if (row.ItemArray.Length != 2) + { + return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"only 2 columns allowed on row {i + 1}" }; + } + + var fromUrl = row[0].ToString(); + var toUrl = row[1].ToString(); + + if (!string.IsNullOrWhiteSpace(fromUrl) && !string.IsNullOrWhiteSpace(toUrl)) + { + if (UrlExists(fromUrl)) + { + return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"Redirect already exists for 'from' URL: {fromUrl} validation aborted." }; + } + parsedData.Add(fromUrl, toUrl); + } + else + { + return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"row {i + 1}" }; + } + } + + if (importFile) + { + foreach (var entry in parsedData) + { + this.SaveRedirect(entry); + } + } + + return new HttpResponseMessage { StatusCode = HttpStatusCode.OK }; + } + + private void SetDomain(string domain) + { + int.TryParse(domain, out var domainId); + using var ctx = _umbracoContextFactory.EnsureUmbracoContext(); + + var foundDomain = ctx.UmbracoContext.Domains?.GetAll(false).FirstOrDefault(it => it.Id == domainId); + if (foundDomain is not null) + { + _selectedDomain = foundDomain;; + } + } + + private void SaveRedirect(KeyValuePair 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); + } +} diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/SeoToolkit.Umbraco.Redirects.Core.csproj b/src/SeoToolkit.Umbraco.Redirects.Core/SeoToolkit.Umbraco.Redirects.Core.csproj index f89545a3..1ca227e8 100644 --- a/src/SeoToolkit.Umbraco.Redirects.Core/SeoToolkit.Umbraco.Redirects.Core.csproj +++ b/src/SeoToolkit.Umbraco.Redirects.Core/SeoToolkit.Umbraco.Redirects.Core.csproj @@ -15,6 +15,11 @@ 3.5.1 + + + + + From 5ab2c094bca900ee9976ddc244ac841d48e75d61 Mon Sep 17 00:00:00 2001 From: Ambert van Unen Date: Fri, 9 Aug 2024 09:34:53 +0200 Subject: [PATCH 2/6] Added most logic --- .../Composers/RedirectsComposer.cs | 5 +- .../Controllers/RedirectsController.cs | 50 ++-- .../Helpers/RedirectsImportHelper.cs | 214 +++++++++++++----- .../Models/Business/ImportStatus.cs | 13 ++ .../Redirects/Dialogs/import.controller.js | 136 +++++++++++ .../wwwroot/Redirects/Dialogs/import.html | 66 ++++++ .../Dialogs/redirectsapi.resource.js | 25 ++ .../wwwroot/Redirects/lang/en-us.xml | 10 + .../wwwroot/Redirects/lang/nl-nl.xml | 10 + .../wwwroot/Redirects/package.manifest | 4 +- .../backoffice/Redirects/list.controller.js | 26 ++- .../wwwroot/backoffice/Redirects/list.html | 11 +- 12 files changed, 476 insertions(+), 94 deletions(-) create mode 100644 src/SeoToolkit.Umbraco.Redirects.Core/Models/Business/ImportStatus.cs create mode 100644 src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js create mode 100644 src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.html create mode 100644 src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/redirectsapi.resource.js diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Composers/RedirectsComposer.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Composers/RedirectsComposer.cs index 58891e0d..de000372 100644 --- a/src/SeoToolkit.Umbraco.Redirects.Core/Composers/RedirectsComposer.cs +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Composers/RedirectsComposer.cs @@ -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; @@ -42,11 +42,12 @@ public void Compose(IUmbracoBuilder builder) { builder.Trees().RemoveTreeController(); } - + builder.Components().Append(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddTransient(); if (!disabledModules.Contains(DisabledModuleConstant.Middleware)) { diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs index 6579c0bd..328fc6b5 100644 --- a/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs @@ -1,12 +1,11 @@ using System; using System.IO; using System.Linq; -using System.Net.Http; 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; @@ -28,16 +27,18 @@ public class RedirectsController : UmbracoAuthorizedApiController private readonly IUmbracoContextFactory _umbracoContextFactory; private readonly ILocalizationService _localizationService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly RedirectsImportHelper _redirectsImportHelper; public RedirectsController(IRedirectsService redirectsService, IUmbracoContextFactory umbracoContextFactory, ILocalizationService localizationService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, RedirectsImportHelper redirectsImportHelper) { _redirectsService = redirectsService; _umbracoContextFactory = umbracoContextFactory; _localizationService = localizationService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _redirectsImportHelper = redirectsImportHelper; } [HttpPost] @@ -140,38 +141,27 @@ public IActionResult Delete(DeleteRedirectsPostModel postModel) _redirectsService.Delete(postModel.Ids); return GetAll(1, 20); } - + [HttpPost] public IActionResult ValidateRedirects(ImportRedirectsFileExtension fileExtension, string domain) { var files = HttpContext.Request.Form.Files; if (files.Count != 1 || files[0].Length == 0) { - return BadRequest(new { isValid = false, Error = "Please select a file"}); + return Ok(new ImportStatus(StatusCodes.Status400BadRequest, "Please select a file")); } - + var file = HttpContext.Request.Form.Files[0]; using var memoryStream = new MemoryStream(); file.CopyTo(memoryStream); - return Ok(Validate(fileExtension, memoryStream, domain, false)); - } - - private IActionResult Validate(ImportRedirectsFileExtension fileExtension, MemoryStream memoryStream, - string domain, bool importFile) - { - HttpResponseMessage validationResult; - switch (fileExtension) + var result = _redirectsImportHelper.Validate(fileExtension, memoryStream, domain); + if (result.Success) { - case ImportRedirectsFileExtension.Csv: - validationResult = redirectsImportHelper.ImportCsv(memoryStream, importFile, domain); - break; - case ImportRedirectsFileExtension.Excel: - validationResult = redirectsImportHelper.ImportExcel(memoryStream, importFile, domain); - break; - default: - return BadRequest("Invalid filetype, you may only use .csv or .xls"); + return Ok(new ImportStatus(StatusCodes.Status200OK)); } + + return Ok(!string.IsNullOrWhiteSpace(result.Status) ? new ImportStatus(StatusCodes.Status400BadRequest, result.Status) : new ImportStatus(StatusCodes.Status400BadRequest, "Something went wrong during the validation")); } public IActionResult ImportRedirects() @@ -182,16 +172,22 @@ public IActionResult ImportRedirects() if (fileContent == null || fileExtensionString == null || domain == null) { - return BadRequest("Something went wrong during import, please try again"); + return Ok(new ImportStatus(StatusCodes.Status400BadRequest, "Something went wrong during import, please try again")); } - + if (!Enum.TryParse(fileExtensionString, out ImportRedirectsFileExtension fileExtension)) { - return BadRequest("Invalid file extension."); + return Ok(new ImportStatus(StatusCodes.Status400BadRequest, "Invalid file extension.")); } - + using var memoryStream = new MemoryStream(fileContent); - return Ok(Validate(fileExtension, memoryStream, domain,true)); + var result = _redirectsImportHelper.Import(fileExtension, memoryStream, domain); + if (result.Success) + { + return Ok(new ImportStatus(StatusCodes.Status200OK)); + } + + return !string.IsNullOrWhiteSpace(result.Status) ? Ok(new ImportStatus(StatusCodes.Status400BadRequest, result.Status)) : Ok(new ImportStatus(StatusCodes.Status400BadRequest, "Something went wrong during the import")); } } } diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs index b28f46e3..d3ad53d2 100644 --- a/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs @@ -1,13 +1,19 @@ +using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net; -using System.Net.Http; +using System.Text; +using ExcelDataReader; +using Microsoft.AspNetCore.Http; using Microsoft.VisualBasic.FileIO; +using SeoToolkit.Umbraco.Redirects.Core.Constants; +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; @@ -16,16 +22,83 @@ public class RedirectsImportHelper private Domain _selectedDomain; private readonly IRedirectsService _redirectsService; private readonly IUmbracoContextFactory _umbracoContextFactory; + private readonly IHttpContextAccessor _httpContextAccessor; - public RedirectsImportHelper(IRedirectsService redirectsService, IUmbracoContextFactory umbracoContextFactory) + public RedirectsImportHelper(IRedirectsService redirectsService, IUmbracoContextFactory umbracoContextFactory, IHttpContextAccessor httpContextAccessor) { _redirectsService = redirectsService; _umbracoContextFactory = umbracoContextFactory; + _httpContextAccessor = httpContextAccessor; } - public HttpResponseMessage ImportCsv(Stream fileStream, bool importFile, string domain) + public Attempt?, string> Validate(ImportRedirectsFileExtension fileExtension, MemoryStream memoryStream, string domain) { SetDomain(domain); + Attempt, string> validationResult; + switch (fileExtension) + { + case ImportRedirectsFileExtension.Csv: + validationResult = ValidateCsv(memoryStream); + break; + case ImportRedirectsFileExtension.Excel: + validationResult = ValidateExcel(memoryStream); + break; + default: + return Attempt?, string>.Fail("Invalid filetype, you may only use .csv or .xls", result: null); + } + + if (validationResult.Success) + { + if (_httpContextAccessor.HttpContext is null) + { + return Attempt?, string>.Fail("Could not access context", result: null); + } + // 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 Attempt?, string>.Succeed(string.Empty, validationResult.Result); + } + + return validationResult; + } + + public Attempt?, 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) + { + 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?, 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)) @@ -33,113 +106,134 @@ public HttpResponseMessage ImportCsv(Stream fileStream, bool importFile, string { parser.TextFieldType = FieldType.Delimited; parser.SetDelimiters(","); - var parsedData = new Dictionary(); - + var parsedData = new Dictionary(); + while (!parser.EndOfData) { var fields = parser.ReadFields(); + if (fields?.Length != 2) { - return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"Validation failed: only 2 columns allowed on line {parser.LineNumber}" }; + return Attempt?, string>.Fail($"Validation Fail: only 2 columns allowed on line {parser.LineNumber}", result: null); } - - if(!string.IsNullOrWhiteSpace(fields[0]) && !string.IsNullOrWhiteSpace(fields[1])){ - - if(UrlExists(fields[0])) + 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 new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"Redirect already exists for 'from' URL: {fields[0]} validation aborted." }; + return Attempt?, string>.Fail($"line {parser.LineNumber}", result: null); } - parsedData.Add(fields[0], fields[1]); - + if(UrlExists(fromUrl)) + { + return Attempt?, string>.Fail($"Redirect already exists for 'from' URL: {fromUrl} validation aborted.", result: null); + } + parsedData.Add(fromUrl, toUrl); + } else { - return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"line {parser.LineNumber}" }; - } - } - - if (importFile) - { - foreach (var entry in parsedData) - { - this.SaveRedirect(entry); + return Attempt?, string>.Fail($"line {parser.LineNumber}", result: null); } } + return Attempt?, string>.Succeed(string.Empty, parsedData); } - return new HttpResponseMessage { StatusCode = HttpStatusCode.OK }; - } - - private bool UrlExists(string oldUrl) - { - var existingRedirects = _redirectsService.GetAll(1, 10, null, null, oldUrl.TrimEnd('/')); - return existingRedirects.TotalItems > 0; + } - - public HttpResponseMessage ImportExcel(Stream fileStream, bool importFile, string domain) + private Attempt?, string> ValidateExcel(Stream fileStream) { - SetDomain(domain); fileStream.Position = 0; - System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); using var reader = ExcelReaderFactory.CreateReader(fileStream); var result = reader.AsDataSet(); - var dataTable = result.Tables[0]; - + var dataTable = result.Tables[0]; + var parsedData = new Dictionary(); - + for (var i = 0; i < dataTable.Rows.Count; i++) { var row = dataTable.Rows[i]; if (row.ItemArray.Length != 2) { - return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"only 2 columns allowed on row {i + 1}" }; + return Attempt?, string>.Fail($"only 2 columns allowed on row {i + 1}"); } - - var fromUrl = row[0].ToString(); - var toUrl = row[1].ToString(); - + + 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?, string>.Fail($"row {i + 1}", result: null); + } + if (UrlExists(fromUrl)) { - return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"Redirect already exists for 'from' URL: {fromUrl} validation aborted." }; + return Attempt?, string>.Fail($"Redirect already exists for 'from' URL: {fromUrl} validation aborted."); } parsedData.Add(fromUrl, toUrl); } else { - return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest, ReasonPhrase = $"row {i + 1}" }; + return Attempt?, string>.Fail($"row {i + 1}"); } } - if (importFile) - { - foreach (var entry in parsedData) - { - this.SaveRedirect(entry); - } - } - - return new HttpResponseMessage { StatusCode = HttpStatusCode.OK }; + return Attempt?, string>.Succeed(string.Empty, parsedData); } private void SetDomain(string domain) { - int.TryParse(domain, out var domainId); - using var ctx = _umbracoContextFactory.EnsureUmbracoContext(); + 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 not null) + if (foundDomain is null) { - _selectedDomain = foundDomain;; + 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 entry) { - var redirect = new Redirect - { + var redirect = new Redirect + { Domain = _selectedDomain, CustomDomain = null, Id = 0, diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Models/Business/ImportStatus.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Models/Business/ImportStatus.cs new file mode 100644 index 00000000..13a72b30 --- /dev/null +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Models/Business/ImportStatus.cs @@ -0,0 +1,13 @@ +namespace SeoToolkit.Umbraco.Redirects.Core.Models.Business; + +public class ImportStatus +{ + public int StatusCode; + public string Error; + + public ImportStatus(int statusCode, string error = "") + { + StatusCode = statusCode; + Error = error; + } +} \ No newline at end of file diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js new file mode 100644 index 00000000..0018dd81 --- /dev/null +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js @@ -0,0 +1,136 @@ +(function () { + "use strict"; + + function importController($http, $scope, formHelper, localizationService, redirectsApiResource, notificationsService) { + + var vm = this; + + vm.submit = submit; + vm.close = close; + vm.handleFiles = handleFiles; + vm.submit = submit; + vm.import = importFile; + vm.validated = false; + vm.validationSuccess = ""; + vm.validationFailed = ""; + vm.importSuccess = ""; + vm.importFailed = ""; + vm.fileTypePropertyLabel = "Select the file type"; + vm.fileTypePropertyDescription = "Select the type of file you want to import the redirects from"; + vm.filePropertyLabel = "Select a file to import"; + vm.domainPropertyLabel = "Select the domain to import for"; + vm.domainPropertyDescription = "if nothing is selected, redirects will be imported for all sites"; + + var labelKeys = [ + "redirect_fileTypePropertyLabel", "redirect_fileTypePropertyDescription", + "redirect_filePropertyLabel", + "redirect_validationSuccess", "redirect_validationFailed", + "redirect_importSuccess", "redirect_importFailed", + "redirect_domainPropertyLabel","redirect_domainPropertyDescription", + ]; + localizationService.localizeMany(labelKeys).then(function (data) { + vm.fileTypeSelection.label = data[0]; + vm.fileTypeSelection.description = data[1]; + vm.fileSelection.label = data[2]; + vm.validationSuccess = data[3]; + vm.validationFailed = data[4]; + vm.importSuccess = data[5]; + vm.importFailed = data[6]; + vm.domainPropertyLabel = data[7]; + vm.domainPropertyDescription = data[8]; + }); + + vm.fileTypes = [ + { + label:"CSV", + extension:".csv" + }, + { + label:"Excel", + extension:".xls,.xlsx" + }, + ]; + + vm.fileTypeSelection = { + alias: "fileType", + label: vm.fileTypePropertyLabel, + description: vm.fileTypePropertyDescription, + value: 0, + validation: { + mandatory: true + } + } + + vm.domainSelection = { + alias: "domain", + label: vm.domainPropertyLabel, + description: vm.domainPropertyDescription, + value: "0", + validation: { + mandatory: true + } + } + + vm.fileSelection = { + alias: "file", + label: vm.filePropertyLabel, + value: 0, + validation: { + mandatory: true + } + } + + function handleFiles(files) { + if (files && files.length > 0) { + vm.fileSelection.value = files[0]; + } + } + + function submit() { + if (formHelper.submitForm({ scope: $scope, formCtrl: $scope.createRedirectForm })) { + redirectsApiResource.validateRedirects(vm.fileTypeSelection.value.label, vm.fileSelection.value, vm.domainSelection.value).then(function (response) { + console.log(response); + if (response.StatusCode == 200) { + vm.notification = vm.validationSuccess; + vm.validated = true; + } else { + vm.notification = `${vm.validationFailed} ${response.Error}`; + vm.validated = false; + } + }); + } + } + function importFile() { + if (formHelper.submitForm({ scope: $scope, formCtrl: $scope.createRedirectForm })) { + redirectsApiResource.importRedirects().then(function (response) { + if (response.StatusCode == 200) { + notificationsService.success(`${vm.fileSelection.value.name} ${vm.importSuccess}`); + vm.validated = true; + $scope.model.close(); + } else { + vm.notification = `${vm.importFailed} ${response.Error}`; + vm.validated = false; + } + }); + } + } + function close() { + if ($scope.model.close) { + $scope.model.close(); + } + } + + init(); + + function init(){ + $http.get("backoffice/SeoToolkit/Redirects/GetDomains").then(function (response) { + vm.domains = response.data.map(function (item) { + return { id: item.Id, name: item.Name } + }); + vm.domains.splice(0, 0, { id: 0, name: "All Sites" }); + }); + } + } + + angular.module("umbraco").controller("SeoToolkit.Redirects.ImportController", importController); +})(); \ No newline at end of file diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.html b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.html new file mode 100644 index 00000000..cde91174 --- /dev/null +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.html @@ -0,0 +1,66 @@ +
+
+ + + + + + + +
+ +
+
+ +
+ +
+
+ + + + + +
+ {{vm.notification}} + + +
+
+
+
+ + + + + + + +
+
+
\ No newline at end of file diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/redirectsapi.resource.js b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/redirectsapi.resource.js new file mode 100644 index 00000000..6e650dbb --- /dev/null +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/redirectsapi.resource.js @@ -0,0 +1,25 @@ +angular.module('umbraco.resources').factory('redirectsApiResource', + function (umbRequestHelper, Upload) { + // the factory object returned + var baseUrl = "backoffice/SeoToolkit/Redirects/"; + + return { + validateRedirects: function (fileExtension, file, domainId) { + return umbRequestHelper.resourcePromise( + Upload.upload({ + url: baseUrl + 'ValidateRedirects?fileExtension=' + fileExtension + '&domain=' + domainId, + file: file + }), + "Failed to import redirects" + ); + }, + importRedirects: function (fileExtension) { + return umbRequestHelper.resourcePromise( + Upload.upload({ + url: baseUrl + 'ImportRedirects' + }), + "Failed to import redirects" + ); + }, + }; + }); diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/lang/en-us.xml b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/lang/en-us.xml index ea783113..11fd2b37 100644 --- a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/lang/en-us.xml +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/lang/en-us.xml @@ -2,5 +2,15 @@ Create Redirect + Import redirects + Select the file type + Select the type of file you want to import the redirects from + Select a file to import + Validated successfully + Something went wrong: + Imported successfully + Something went wrong: + Select the domain to import for + if nothing is selected, redirects will be active for all domains \ No newline at end of file diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/lang/nl-nl.xml b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/lang/nl-nl.xml index d680bdc6..e6224f1a 100644 --- a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/lang/nl-nl.xml +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/lang/nl-nl.xml @@ -2,5 +2,15 @@ Redirect aanmaken + Redirects importeren + Selecteer het type bestand + Selecteer hier het type bestand dat geimporteerd moet worden + Selecteer het bestand + Gevalideerd + Er ging iets mis: + Succesvol geimporteerd + Er ging iets mis: + Selecteer hier het domein + Als niets is geselecteerd worden de redirects actief voor alle domeinen \ No newline at end of file diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/package.manifest b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/package.manifest index d5655b5b..9ab4e1e3 100644 --- a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/package.manifest +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/package.manifest @@ -2,7 +2,9 @@ "javascript": [ "/App_Plugins/SeoToolkit/backoffice/Redirects/list.controller.js", "/App_Plugins/SeoToolkit/Redirects/Dialogs/createRedirect.controller.js", - "/App_Plugins/SeoToolkit/Redirects/Dialogs/linkPicker.controller.js" + "/App_Plugins/SeoToolkit/Redirects/Dialogs/linkPicker.controller.js", + "/App_Plugins/SeoToolkit/Redirects/Dialogs/redirectsapi.resource.js", + "/App_Plugins/SeoToolkit/Redirects/Dialogs/import.controller.js" ], "css": [ "/App_Plugins/SeoToolkit/Redirects/css/main.css" diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.controller.js b/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.controller.js index 0a272445..45bb1584 100644 --- a/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.controller.js +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.controller.js @@ -1,7 +1,7 @@ (function () { "use strict"; - function redirectListController($timeout, $http, listViewHelper, notificationsService, editorService) { + function redirectListController($timeout, $http, listViewHelper, notificationsService, editorService, localizationService) { var vm = this; vm.items = []; @@ -37,8 +37,16 @@ vm.search = search; vm.create = openRedirectDialog; + vm.import = openImportDialog; vm.deleteSelection = deleteSelection; + vm.dialogLabel = "import redirects" + + var labelKeys = ["redirect_importButton"]; + localizationService.localizeMany(labelKeys).then(function (data) { + vm.dialogLabel = data[0]; + }); + vm.pageNumber = 1; vm.pageSize = 20; vm.totalPages = 1; @@ -98,6 +106,22 @@ goToPage(1); } + function openImportDialog(model) { + var redirectDialogOptions = { + title: vm.dialogLabel, + view: "/App_Plugins/SeoToolkit/Redirects/Dialogs/import.html", + size: "small", + close: function () { + editorService.close(); + } + }; + if (model) { + redirectDialogOptions.redirect = model; + } + + editorService.open(redirectDialogOptions); + } + function openRedirectDialog(model) { var redirectDialogOptions = { title: "Create redirect", diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.html b/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.html index 8014ccfd..e9a26143 100644 --- a/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.html +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.html @@ -10,6 +10,11 @@ size="xs" label-key="redirect_create" action="vm.create()">Add Redirect + Import redirects @@ -19,7 +24,7 @@ label-key="buttons_clearSelection" action="vm.clearSelection()">Clear Selection - + @@ -34,9 +39,9 @@ action="vm.deleteSelection()">Delete - + - +
Date: Thu, 15 Aug 2024 11:30:11 +0200 Subject: [PATCH 3/6] Cleanup / some improvemend in statusses / statii? --- .../Controllers/RedirectsController.cs | 51 ++++++++---- .../Helpers/RedirectsImportHelper.cs | 81 +++++++++---------- .../Models/Business/ImportStatus.cs | 13 --- .../Redirects/Dialogs/import.controller.js | 32 ++++---- .../Dialogs/redirectsapi.resource.js | 18 +++-- 5 files changed, 100 insertions(+), 95 deletions(-) delete mode 100644 src/SeoToolkit.Umbraco.Redirects.Core/Models/Business/ImportStatus.cs diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs index 328fc6b5..37aa206c 100644 --- a/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs @@ -28,17 +28,20 @@ public class RedirectsController : UmbracoAuthorizedApiController 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, RedirectsImportHelper redirectsImportHelper) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, RedirectsImportHelper redirectsImportHelper, IHttpContextAccessor httpContextAccessor) { _redirectsService = redirectsService; _umbracoContextFactory = umbracoContextFactory; _localizationService = localizationService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; _redirectsImportHelper = redirectsImportHelper; + _httpContextAccessor = httpContextAccessor; } [HttpPost] @@ -143,28 +146,42 @@ public IActionResult Delete(DeleteRedirectsPostModel postModel) } [HttpPost] - public IActionResult ValidateRedirects(ImportRedirectsFileExtension fileExtension, string domain) + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public IActionResult Validate(ImportRedirectsFileExtension fileExtension, string domain, IFormFile file) { - var files = HttpContext.Request.Form.Files; - if (files.Count != 1 || files[0].Length == 0) + if (file.Length == 0) { - return Ok(new ImportStatus(StatusCodes.Status400BadRequest, "Please select a file")); + return BadRequest("Please select a file"); } - - var file = HttpContext.Request.Form.Files[0]; + using var memoryStream = new MemoryStream(); file.CopyTo(memoryStream); var result = _redirectsImportHelper.Validate(fileExtension, memoryStream, domain); if (result.Success) { - return Ok(new ImportStatus(StatusCodes.Status200OK)); + if (_httpContextAccessor.HttpContext is null) + { + 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 Ok(!string.IsNullOrWhiteSpace(result.Status) ? new ImportStatus(StatusCodes.Status400BadRequest, result.Status) : new ImportStatus(StatusCodes.Status400BadRequest, "Something went wrong during the validation")); + return UnprocessableEntity(!string.IsNullOrWhiteSpace(result.Status) ? result.Status : "Something went wrong during the validation"); } - public IActionResult ImportRedirects() + [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); @@ -172,22 +189,22 @@ public IActionResult ImportRedirects() if (fileContent == null || fileExtensionString == null || domain == null) { - return Ok(new ImportStatus(StatusCodes.Status400BadRequest, "Something went wrong during import, please try again")); + return BadRequest("Something went wrong during import, please try again"); } - + if (!Enum.TryParse(fileExtensionString, out ImportRedirectsFileExtension fileExtension)) { - return Ok(new ImportStatus(StatusCodes.Status400BadRequest, "Invalid file extension.")); + return UnprocessableEntity("Invalid file extension."); } - + using var memoryStream = new MemoryStream(fileContent); var result = _redirectsImportHelper.Import(fileExtension, memoryStream, domain); if (result.Success) { - return Ok(new ImportStatus(StatusCodes.Status200OK)); + return Ok(); } - - return !string.IsNullOrWhiteSpace(result.Status) ? Ok(new ImportStatus(StatusCodes.Status400BadRequest, result.Status)) : Ok(new ImportStatus(StatusCodes.Status400BadRequest, "Something went wrong during the import")); + + return UnprocessableEntity(!string.IsNullOrWhiteSpace(result.Status) ? result.Status : "Something went wrong during the import"); } } } diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs index d3ad53d2..d3b3d538 100644 --- a/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs @@ -4,9 +4,7 @@ using System.Linq; using System.Text; using ExcelDataReader; -using Microsoft.AspNetCore.Http; using Microsoft.VisualBasic.FileIO; -using SeoToolkit.Umbraco.Redirects.Core.Constants; using SeoToolkit.Umbraco.Redirects.Core.Enumerators; using SeoToolkit.Umbraco.Redirects.Core.Interfaces; using SeoToolkit.Umbraco.Redirects.Core.Models.Business; @@ -22,13 +20,11 @@ public class RedirectsImportHelper private Domain _selectedDomain; private readonly IRedirectsService _redirectsService; private readonly IUmbracoContextFactory _umbracoContextFactory; - private readonly IHttpContextAccessor _httpContextAccessor; - public RedirectsImportHelper(IRedirectsService redirectsService, IUmbracoContextFactory umbracoContextFactory, IHttpContextAccessor httpContextAccessor) + public RedirectsImportHelper(IRedirectsService redirectsService, IUmbracoContextFactory umbracoContextFactory) { _redirectsService = redirectsService; _umbracoContextFactory = umbracoContextFactory; - _httpContextAccessor = httpContextAccessor; } public Attempt?, string> Validate(ImportRedirectsFileExtension fileExtension, MemoryStream memoryStream, string domain) @@ -49,14 +45,6 @@ public RedirectsImportHelper(IRedirectsService redirectsService, IUmbracoContext if (validationResult.Success) { - if (_httpContextAccessor.HttpContext is null) - { - return Attempt?, string>.Fail("Could not access context", result: null); - } - // 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 Attempt?, string>.Succeed(string.Empty, validationResult.Result); } @@ -144,50 +132,59 @@ private bool UrlExists(string oldUrl) } - private Attempt?, string> ValidateExcel(Stream fileStream) + private Attempt?, string> ValidateExcel(Stream fileStream) { fileStream.Position = 0; Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - using var reader = ExcelReaderFactory.CreateReader(fileStream); - var result = reader.AsDataSet(); - var dataTable = result.Tables[0]; - - var parsedData = new Dictionary(); - - for (var i = 0; i < dataTable.Rows.Count; i++) + try { - var row = dataTable.Rows[i]; - if (row.ItemArray.Length != 2) - { - return Attempt?, string>.Fail($"only 2 columns allowed on row {i + 1}"); - } + using var reader = ExcelReaderFactory.CreateReader(fileStream); + var result = reader.AsDataSet(); + var dataTable = result.Tables[0]; - var fromUrl = CleanFromUrl(row[0].ToString()); - var toUrl = Uri.IsWellFormedUriString(row[1].ToString(), UriKind.Absolute) ? - row[1].ToString() : - row[1].ToString()?.EnsureEndsWith("/").ToLower(); + var parsedData = new Dictionary(); - if (!string.IsNullOrWhiteSpace(fromUrl) && !string.IsNullOrWhiteSpace(toUrl)) + for (var i = 0; i < dataTable.Rows.Count; i++) { - if (!Uri.IsWellFormedUriString(fromUrl, UriKind.Relative)) + var row = dataTable.Rows[i]; + if (row.ItemArray.Length != 2) { - return Attempt?, string>.Fail($"row {i + 1}", result: null); + return Attempt?, string>.Fail($"only 2 columns allowed on row {i + 1}"); } - if (UrlExists(fromUrl)) + 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?, string>.Fail($"row {i + 1}", result: null); + } + + if (UrlExists(fromUrl)) + { + return Attempt?, string>.Fail( + $"Redirect already exists for 'from' URL: {fromUrl} validation aborted."); + } + + parsedData.Add(fromUrl, toUrl); + } + else { - return Attempt?, string>.Fail($"Redirect already exists for 'from' URL: {fromUrl} validation aborted."); + return Attempt?, string>.Fail($"row {i + 1}"); } - parsedData.Add(fromUrl, toUrl); } - else - { - return Attempt?, string>.Fail($"row {i + 1}"); - } - } - return Attempt?, string>.Succeed(string.Empty, parsedData); + return Attempt?, string>.Succeed(string.Empty, parsedData); + } + catch + { + return Attempt?, string>.Fail("Invalid file type"); + } } private void SetDomain(string domain) diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Models/Business/ImportStatus.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Models/Business/ImportStatus.cs deleted file mode 100644 index 13a72b30..00000000 --- a/src/SeoToolkit.Umbraco.Redirects.Core/Models/Business/ImportStatus.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace SeoToolkit.Umbraco.Redirects.Core.Models.Business; - -public class ImportStatus -{ - public int StatusCode; - public string Error; - - public ImportStatus(int statusCode, string error = "") - { - StatusCode = statusCode; - Error = error; - } -} \ No newline at end of file diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js index 0018dd81..db47b226 100644 --- a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js @@ -88,32 +88,28 @@ function submit() { if (formHelper.submitForm({ scope: $scope, formCtrl: $scope.createRedirectForm })) { - redirectsApiResource.validateRedirects(vm.fileTypeSelection.value.label, vm.fileSelection.value, vm.domainSelection.value).then(function (response) { - console.log(response); - if (response.StatusCode == 200) { - vm.notification = vm.validationSuccess; - vm.validated = true; - } else { - vm.notification = `${vm.validationFailed} ${response.Error}`; - vm.validated = false; - } + redirectsImportApiResource.validateRedirects(vm.fileTypeSelection.value.label, vm.fileSelection.value, vm.domainSelection.value).then(function (response) { + vm.notification = vm.validationSuccess; + vm.validated = true; + }).catch(function (error) { + vm.notification = `${vm.validationFailed} ${error}`; + vm.validated = false; }); } } function importFile() { if (formHelper.submitForm({ scope: $scope, formCtrl: $scope.createRedirectForm })) { - redirectsApiResource.importRedirects().then(function (response) { - if (response.StatusCode == 200) { - notificationsService.success(`${vm.fileSelection.value.name} ${vm.importSuccess}`); - vm.validated = true; - $scope.model.close(); - } else { - vm.notification = `${vm.importFailed} ${response.Error}`; - vm.validated = false; - } + redirectsImportApiResource.importRedirects().then(function (response) { + notificationsService.success(`${vm.fileSelection.value.name} ${vm.importSuccess}`); + vm.validated = true; + $scope.model.close(); + }).catch(function (error) { + vm.notification = `${vm.validationFailed} ${error}`; + vm.validated = false; }); } } + function close() { if ($scope.model.close) { $scope.model.close(); diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/redirectsapi.resource.js b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/redirectsapi.resource.js index 6e650dbb..ba60a71b 100644 --- a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/redirectsapi.resource.js +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/redirectsapi.resource.js @@ -7,18 +7,26 @@ validateRedirects: function (fileExtension, file, domainId) { return umbRequestHelper.resourcePromise( Upload.upload({ - url: baseUrl + 'ValidateRedirects?fileExtension=' + fileExtension + '&domain=' + domainId, + url: baseUrl + 'Validate?fileExtension=' + fileExtension + '&domain=' + domainId, file: file + }).then(function (response) { + return response; + }).catch(function (error) { + var errorMsg = error.data ? error.data : "Failed to validate redirects"; + return Promise.reject(errorMsg); }), - "Failed to import redirects" ); }, - importRedirects: function (fileExtension) { + importRedirects: function () { return umbRequestHelper.resourcePromise( Upload.upload({ - url: baseUrl + 'ImportRedirects' + url: baseUrl + 'Import' + }).then(function (response) { + return response; + }).catch(function (error) { + var errorMsg = error.data ? error.data : "Failed to import redirects"; + return Promise.reject(errorMsg); }), - "Failed to import redirects" ); }, }; From 880a13b6e0174f3b0ba1555000e233f30e77ca77 Mon Sep 17 00:00:00 2001 From: Ambert Date: Sat, 31 Aug 2024 12:41:25 +0200 Subject: [PATCH 4/6] Minor changes based on pr feedback --- .../Controllers/RedirectsController.cs | 20 ++++++++----------- .../Redirects/Dialogs/import.controller.js | 6 +++--- .../backoffice/Redirects/list.controller.js | 1 + 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs index 37aa206c..cf705412 100644 --- a/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs @@ -29,7 +29,7 @@ public class RedirectsController : UmbracoAuthorizedApiController private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; private readonly RedirectsImportHelper _redirectsImportHelper; private readonly IHttpContextAccessor _httpContextAccessor; - + public RedirectsController(IRedirectsService redirectsService, IUmbracoContextFactory umbracoContextFactory, @@ -155,22 +155,18 @@ public IActionResult Validate(ImportRedirectsFileExtension fileExtension, string { 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) - { - 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); + HttpContext.Session.Set(ImportConstants.SessionAlias, memoryStream.ToArray()); + HttpContext.Session.SetString(ImportConstants.SessionFileTypeAlias, fileExtension.ToString()); + HttpContext.Session.SetString(ImportConstants.SessionDomainId, domain); return Ok(); } @@ -191,19 +187,19 @@ public IActionResult Import() { 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"); } } diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js index db47b226..f2a476c5 100644 --- a/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/Redirects/Dialogs/import.controller.js @@ -88,7 +88,7 @@ function submit() { if (formHelper.submitForm({ scope: $scope, formCtrl: $scope.createRedirectForm })) { - redirectsImportApiResource.validateRedirects(vm.fileTypeSelection.value.label, vm.fileSelection.value, vm.domainSelection.value).then(function (response) { + redirectsApiResource.validateRedirects(vm.fileTypeSelection.value.label, vm.fileSelection.value, vm.domainSelection.value).then(function (response) { vm.notification = vm.validationSuccess; vm.validated = true; }).catch(function (error) { @@ -99,7 +99,7 @@ } function importFile() { if (formHelper.submitForm({ scope: $scope, formCtrl: $scope.createRedirectForm })) { - redirectsImportApiResource.importRedirects().then(function (response) { + redirectsApiResource.importRedirects().then(function (response) { notificationsService.success(`${vm.fileSelection.value.name} ${vm.importSuccess}`); vm.validated = true; $scope.model.close(); @@ -109,7 +109,7 @@ }); } } - + function close() { if ($scope.model.close) { $scope.model.close(); diff --git a/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.controller.js b/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.controller.js index 45bb1584..572ffa4a 100644 --- a/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.controller.js +++ b/src/SeoToolkit.Umbraco.Redirects/wwwroot/backoffice/Redirects/list.controller.js @@ -112,6 +112,7 @@ view: "/App_Plugins/SeoToolkit/Redirects/Dialogs/import.html", size: "small", close: function () { + loadItems(); editorService.close(); } }; From 33119e99036110d5c75441e8f9c35780322a2a6e Mon Sep 17 00:00:00 2001 From: Ambert Date: Sat, 31 Aug 2024 20:11:04 +0200 Subject: [PATCH 5/6] Added check if url doesnt appear double in importfile --- .../Controllers/RedirectsController.cs | 4 +--- .../Helpers/RedirectsImportHelper.cs | 9 ++++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs index cf705412..7c56657c 100644 --- a/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Controllers/RedirectsController.cs @@ -28,20 +28,18 @@ public class RedirectsController : UmbracoAuthorizedApiController 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, RedirectsImportHelper redirectsImportHelper, IHttpContextAccessor httpContextAccessor) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, RedirectsImportHelper redirectsImportHelper) { _redirectsService = redirectsService; _umbracoContextFactory = umbracoContextFactory; _localizationService = localizationService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; _redirectsImportHelper = redirectsImportHelper; - _httpContextAccessor = httpContextAccessor; } [HttpPost] diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs index d3b3d538..5ae276c2 100644 --- a/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs @@ -119,6 +119,10 @@ private bool UrlExists(string oldUrl) { return Attempt?, string>.Fail($"Redirect already exists for 'from' URL: {fromUrl} validation aborted.", result: null); } + if (parsedData.ContainsKey(fromUrl.TrimEnd("/"))) + { + return Attempt?, string>.Fail($"Url appears more then one time in import file: {fromUrl}", result: null); + } parsedData.Add(fromUrl, toUrl); } @@ -170,7 +174,10 @@ private bool UrlExists(string oldUrl) return Attempt?, string>.Fail( $"Redirect already exists for 'from' URL: {fromUrl} validation aborted."); } - + if (parsedData.ContainsKey(fromUrl.TrimEnd("/"))) + { + return Attempt?, string>.Fail($"Url appears more then one time in import file: {fromUrl}", result: null); + } parsedData.Add(fromUrl, toUrl); } else From 98eb7e7be43376d3ec3792fa6b10897194adefd4 Mon Sep 17 00:00:00 2001 From: Ambert Date: Sat, 31 Aug 2024 20:21:04 +0200 Subject: [PATCH 6/6] Added check for exact match --- .../Helpers/RedirectsImportHelper.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs b/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs index 5ae276c2..e59b8c6f 100644 --- a/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs +++ b/src/SeoToolkit.Umbraco.Redirects.Core/Helpers/RedirectsImportHelper.cs @@ -71,6 +71,11 @@ private bool UrlExists(string oldUrl) var existingRedirects = _redirectsService.GetAll(1, 10, null, null, oldUrl.TrimEnd('/')); if (existingRedirects.TotalItems > 0 && existingRedirects.Items is not null) { + if (existingRedirects.Items.All(x => x.OldUrl != oldUrl.TrimEnd('/'))) + { + //exact match not found + return false; + } if (existingRedirects.Items.Count(x => x.Domain is null || x.Domain.Id == 0) > 0) { //url exists without any domain set