├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── CHANGELOG.md ├── Geta.NotFoundHandler.sln ├── LICENSE ├── NuGet.config ├── README.md ├── images ├── icon.png └── redirects.png ├── pack.ps1 ├── src ├── Geta.NotFoundHandler.Admin │ ├── Areas │ │ └── GetaNotFoundHandlerAdmin │ │ │ └── Pages │ │ │ ├── Administer.cshtml │ │ │ ├── Administer.cshtml.cs │ │ │ ├── Base │ │ │ └── AbstractSortablePageModel.cs │ │ │ ├── Components │ │ │ ├── Card │ │ │ │ ├── CardType.cs │ │ │ │ ├── CardTypeExtensions.cs │ │ │ │ ├── CardViewComponent.cs │ │ │ │ ├── CardViewModel.cs │ │ │ │ └── Default.cshtml │ │ │ ├── CheckboxReadonly │ │ │ │ ├── CheckboxReadonlyViewComponent.cs │ │ │ │ ├── CheckboxReadonlyViewModel.cs │ │ │ │ └── Default.cshtml │ │ │ ├── Pager │ │ │ │ ├── Default.cshtml │ │ │ │ ├── PagerViewComponent.cs │ │ │ │ └── PagerViewModel.cs │ │ │ └── SortableHeaderCell │ │ │ │ ├── Default.cshtml │ │ │ │ ├── SortableHeaderCellViewComponent.cs │ │ │ │ └── SortableHeaderCellViewModel.cs │ │ │ ├── Deleted.cshtml │ │ │ ├── Deleted.cshtml.cs │ │ │ ├── Extensions │ │ │ ├── EnumerableExtensions.cs │ │ │ └── ModelStateExtensions.cs │ │ │ ├── Ignored.cshtml │ │ │ ├── Ignored.cshtml.cs │ │ │ ├── Index.cshtml │ │ │ ├── Index.cshtml.cs │ │ │ ├── Infrastructure │ │ │ └── IFormFileExtensions.cs │ │ │ ├── Models │ │ │ ├── DeletedRedirectModel.cs │ │ │ ├── Paging.cs │ │ │ ├── RedirectModel.cs │ │ │ ├── RedirectsRequest.cs │ │ │ ├── RegexRedirectModel.cs │ │ │ ├── SortDirection.cs │ │ │ └── SuggestionRedirectModel.cs │ │ │ ├── Regex.cshtml │ │ │ ├── Regex.cshtml.cs │ │ │ ├── Shared │ │ │ ├── _Layout.cshtml │ │ │ └── _Logo.cshtml │ │ │ ├── Suggestions.cshtml │ │ │ ├── Suggestions.cshtml.cs │ │ │ ├── _ViewImports.cshtml │ │ │ └── _ViewStart.cshtml │ ├── Geta.NotFoundHandler.Admin.csproj │ └── wwwroot │ │ └── GetaNotFoundHandlerAdmin │ │ ├── css │ │ └── dashboard.css │ │ └── js │ │ └── dashboard.js ├── Geta.NotFoundHandler.Optimizely.Commerce │ ├── AutomaticRedirects │ │ ├── CommerceContentKeyProvider.cs │ │ ├── CommerceContentLinkProvider.cs │ │ ├── EntryContentUrlProvider.cs │ │ └── NodeContentUrlProvider.cs │ ├── Geta.NotFoundHandler.Optimizely.Commerce.csproj │ └── Infrastructure │ │ └── Configuration │ │ └── OptimizelyNotFoundHandlerOptionsExtensions.cs ├── Geta.NotFoundHandler.Optimizely │ ├── Constants.cs │ ├── ContainerController.cs │ ├── Core │ │ ├── AutomaticRedirects │ │ │ ├── ChannelMovedContentRegistratorQueue.cs │ │ │ ├── CmsContentKeyProvider.cs │ │ │ ├── CmsContentLinkProvider.cs │ │ │ ├── CmsContentUrlProvider.cs │ │ │ ├── ContentKeyGenerator.cs │ │ │ ├── ContentKeyResult.cs │ │ │ ├── ContentLinkLoader.cs │ │ │ ├── ContentUrlHistory.cs │ │ │ ├── ContentUrlHistoryEvents.cs │ │ │ ├── ContentUrlIndexer.cs │ │ │ ├── ContentUrlLoader.cs │ │ │ ├── DefaultAutomaticRedirectsService.cs │ │ │ ├── IAutomaticRedirectsService.cs │ │ │ ├── IContentKeyProvider.cs │ │ │ ├── IContentLinkProvider.cs │ │ │ ├── IContentUrlHistoryLoader.cs │ │ │ ├── IContentUrlProvider.cs │ │ │ ├── IMovedContentRegistratorQueue.cs │ │ │ ├── IndexContentUrlsJob.cs │ │ │ ├── MovedContentRegistratorBackgroundService.cs │ │ │ ├── RedirectBuilder.cs │ │ │ ├── RegisterMovedContentRedirectsJob.cs │ │ │ ├── TypedUrl.cs │ │ │ └── UrlType.cs │ │ ├── Events │ │ │ └── OptimizelySyncEvents.cs │ │ └── Suggestions │ │ │ └── Jobs │ │ │ └── SuggestionsCleanupJob.cs │ ├── Data │ │ └── SqlContentUrlHistoryRepository.cs │ ├── Geta.NotFoundHandler.Optimizely.Views │ │ └── Views │ │ │ ├── Container │ │ │ └── Index.cshtml │ │ │ ├── Shared │ │ │ └── _ShellLayout.cshtml │ │ │ └── _ViewStart.cshtml │ ├── Geta.NotFoundHandler.Optimizely.csproj │ ├── Infrastructure │ │ ├── Configuration │ │ │ ├── OptimizelyNotFoundHandlerOptions.cs │ │ │ └── ServiceCollectionExtensions.cs │ │ ├── Initialization │ │ │ ├── ApplicationBuilderExtensions.cs │ │ │ └── Upgrader.cs │ │ └── JobStatusLogger.cs │ ├── MenuProvider.cs │ ├── module.config │ └── msbuild │ │ └── CopyModule.targets ├── Geta.NotFoundHandler.Web │ ├── .gitignore │ ├── Geta.NotFoundHandler.Web.csproj │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Startup.cs │ └── appsettings.json └── Geta.NotFoundHandler │ ├── Core │ ├── INotFoundHandler.cs │ ├── Providers │ │ └── RegexRedirects │ │ │ ├── DefaultRegexRedirectsService.cs │ │ │ ├── IRegexRedirectCache.cs │ │ │ ├── IRegexRedirectsService.cs │ │ │ ├── MemoryCacheRegexRedirectRepository.cs │ │ │ ├── RegexRedirect.cs │ │ │ ├── RegexRedirectFactory.cs │ │ │ └── RegexRedirectNotFoundHandler.cs │ ├── Redirects │ │ ├── CustomRedirect.cs │ │ ├── CustomRedirectCollection.cs │ │ ├── CustomRedirectEqualityComparer.cs │ │ ├── CustomRedirectHandler.cs │ │ ├── DefaultRedirectsService.cs │ │ ├── IRedirectHandler.cs │ │ ├── IRedirectsParser.cs │ │ ├── IRedirectsService.cs │ │ ├── RedirectState.cs │ │ ├── RedirectType.cs │ │ ├── RedirectsCsvParser.cs │ │ ├── RedirectsEvents.cs │ │ ├── RedirectsInitializer.cs │ │ ├── RedirectsTxtParser.cs │ │ └── RedirectsXmlParser.cs │ ├── RequestHandler.cs │ ├── RewriteResult.cs │ ├── ScheduledJobs │ │ ├── ApplicationBuilderExtensions.cs │ │ ├── ServiceCollectionExtensions.cs │ │ └── Suggestions │ │ │ └── SuggestionsCleanupJob.cs │ └── Suggestions │ │ ├── DefaultSuggestionService.cs │ │ ├── IRequestLogger.cs │ │ ├── ISuggestionService.cs │ │ ├── ISuggestionsCleanupService.cs │ │ ├── LogEvent.cs │ │ ├── RefererSummary.cs │ │ ├── RequestLogger.cs │ │ ├── SuggestionRedirect.cs │ │ ├── SuggestionSummary.cs │ │ ├── SuggestionsCleanupOptions.cs │ │ └── SuggestionsCleanupService.cs │ ├── Data │ ├── IDataExecutor.cs │ ├── IRedirectLoader.cs │ ├── IRegexRedirectLoader.cs │ ├── IRegexRedirectOrderUpdater.cs │ ├── IRepository.cs │ ├── ISuggestionLoader.cs │ ├── ISuggestionRepository.cs │ ├── SqlDataExecutor.cs │ ├── SqlRedirectRepository.cs │ ├── SqlRegexRedirectRepository.cs │ └── SqlSuggestionRepository.cs │ ├── Geta.NotFoundHandler.csproj │ ├── Infrastructure │ ├── Configuration │ │ ├── FileNotFoundMode.cs │ │ ├── LoggerMode.cs │ │ ├── NotFoundHandlerOptions.cs │ │ └── ServiceCollectionExtensions.cs │ ├── Constants.cs │ ├── Initialization │ │ ├── ApplicationBuilderExtensions.cs │ │ ├── NotFoundHandlerMiddleware.cs │ │ └── Upgrader.cs │ ├── Processing │ │ └── SpanExtensions.cs │ └── Web │ │ └── HttpContextExtensions.cs │ └── Models │ └── CsvImportModel.cs └── tests ├── Geta.NotFoundHandler.Optimizely.Tests ├── AutomaticRedirects │ ├── CmsContentKeyProviderTests.cs │ ├── CmsContentLinkProviderTests.cs │ ├── CmsContentUrlProviderTests.cs │ ├── CommerceContentKeyProviderTests.cs │ ├── CommerceContentLinkProviderTests.cs │ ├── ContentKeyGeneratorTests.cs │ ├── ContentLinkLoaderTests.cs │ ├── ContentUrlIndexerTests.cs │ ├── ContentUrlLoaderTests.cs │ ├── EntryContentUrlProviderTests.cs │ ├── NodeContentUrlProviderTests.cs │ └── RedirectBuilderTests.cs └── Geta.NotFoundHandler.Optimizely.Tests.csproj └── Geta.NotFoundHandler.Tests ├── Base └── Uris.cs ├── CustomRedirectCollectionTests.cs ├── ExternalHandlerTests.cs ├── Geta.NotFoundHandler.Tests.csproj ├── Hosting ├── RedirectServerBuilder.cs └── TestServerBuilder.cs ├── Providers └── RegexNotFoundHandlerTests.cs └── RequestHandlerTests.cs /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | concurrency: 9 | group: "build-${{ github.ref_name }}" 10 | cancel-in-progress: false 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: windows-latest 16 | steps: 17 | - name: Set up JDK 11 18 | uses: actions/setup-java@v4 19 | with: 20 | java-version: 11 21 | distribution: 'temurin' 22 | 23 | - name: Checkout repository with submodules 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | submodules: recursive 28 | - name: Cache NuGet packages 29 | uses: actions/cache@v3 30 | with: 31 | path: ~/.nuget/packages 32 | key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} 33 | restore-keys: | 34 | ${{ runner.os }}-nuget- 35 | - name: Cache SonarCloud packages 36 | uses: actions/cache@v4 37 | with: 38 | path: ~\sonar\cache 39 | key: ${{ runner.os }}-sonar 40 | restore-keys: ${{ runner.os }}-sonar 41 | 42 | - name: Cache SonarCloud scanner 43 | id: cache-sonar-scanner 44 | uses: actions/cache@v4 45 | with: 46 | path: .\.sonar\scanner 47 | key: ${{ runner.os }}-sonar-scanner 48 | restore-keys: ${{ runner.os }}-sonar-scanner 49 | 50 | - name: Install SonarCloud scanner 51 | if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' 52 | shell: powershell 53 | run: | 54 | New-Item -Path .\.sonar\scanner -ItemType Directory 55 | dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner 56 | 57 | - name: Restore dependencies 58 | run: dotnet restore 59 | 60 | - name: Build and analyze 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 64 | shell: powershell 65 | run: | 66 | .\.sonar\scanner\dotnet-sonarscanner begin /k:"Geta_${{ github.event.repository.name }}" /o:"geta" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths=**/**/coverage.opencover.xml 67 | dotnet build 68 | dotnet test --filter Category!=Integration /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=coverage 69 | .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" 70 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | concurrency: 7 | group: "release-${{ github.ref_name }}" 8 | cancel-in-progress: false 9 | permissions: 10 | contents: write 11 | packages: write 12 | 13 | jobs: 14 | build: 15 | name: Release 16 | runs-on: windows-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | submodules: recursive 22 | - name: Cache NuGet packages 23 | uses: actions/cache@v3 24 | with: 25 | path: ~/.nuget/packages 26 | key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} 27 | restore-keys: | 28 | ${{ runner.os }}-nuget- 29 | - name: Verify commit exists in origin/master 30 | run: | 31 | git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* 32 | git branch --remote --contains | grep origin/master 33 | - name: Set VERSION variable from tag 34 | run: | 35 | $version = ("${{github.ref_name}}").Remove(0, 1) 36 | echo "VERSION=$version" >> $env:GITHUB_ENV 37 | - name: Release 38 | run: | 39 | echo "${env:VERSION}" 40 | dotnet build --configuration Release /p:Version=${{env.VERSION}} 41 | - name: Test 42 | run: dotnet test --configuration Release /p:Version=${{env.VERSION}} --no-build 43 | - name: Pack 44 | run: dotnet pack --configuration Release /p:Version=${{env.VERSION}} --no-build --output . 45 | - name: Push 46 | run: | 47 | dotnet nuget push Geta.NotFoundHandler.${{env.VERSION}}.nupkg --source https://nuget.pkg.github.com/Geta/index.json --api-key ${{env.GITHUB_TOKEN}} 48 | dotnet nuget push Geta.NotFoundHandler.Admin.${{env.VERSION}}.nupkg --source https://nuget.pkg.github.com/Geta/index.json --api-key ${{env.GITHUB_TOKEN}} 49 | dotnet nuget push Geta.NotFoundHandler.Optimizely.${{env.VERSION}}.nupkg --source https://nuget.pkg.github.com/Geta/index.json --api-key ${{env.GITHUB_TOKEN}} 50 | dotnet nuget push Geta.NotFoundHandler.Optimizely.Commerce.${{env.VERSION}}.nupkg --source https://nuget.pkg.github.com/Geta/index.json --api-key ${{env.GITHUB_TOKEN}} 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | - name: Create GitHub Release with Auto-Generated Notes 54 | run: | 55 | gh release create ${{ github.ref_name }} --generate-notes 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | - name: Loop through all .nupkg files in the current directory and upload them to the release 59 | run: | 60 | Get-ChildItem -Filter *.nupkg -Recurse | ForEach-Object { 61 | Write-Host "Uploading file: $($_.Name)" 62 | gh release upload ${{ github.ref_name }} $_.FullName --clobber 63 | } 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | build/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | tmp 25 | 26 | # Visual Studo 2015 cache/options directory 27 | .vs/ 28 | 29 | # MSTest test Results 30 | [Tt]est[Rr]esult*/ 31 | [Bb]uild[Ll]og.* 32 | 33 | # NUNIT 34 | *.VisualState.xml 35 | TestResult.xml 36 | 37 | # Build Results of an ATL Project 38 | [Dd]ebugPS/ 39 | [Rr]eleasePS/ 40 | dlldata.c 41 | 42 | *_i.c 43 | *_p.c 44 | *_i.h 45 | *.ilk 46 | *.meta 47 | *.obj 48 | *.pch 49 | *.pdb 50 | *.pgc 51 | *.pgd 52 | *.rsp 53 | *.sbr 54 | *.tlb 55 | *.tli 56 | *.tlh 57 | *.tmp 58 | *.tmp_proj 59 | *.log 60 | *.vspscc 61 | *.vssscc 62 | .builds 63 | *.pidb 64 | *.svclog 65 | *.scc 66 | 67 | # Chutzpah Test files 68 | _Chutzpah* 69 | 70 | # Visual C++ cache files 71 | ipch/ 72 | *.aps 73 | *.ncb 74 | *.opensdf 75 | *.sdf 76 | *.cachefile 77 | 78 | # Visual Studio profiler 79 | *.psess 80 | *.vsp 81 | *.vspx 82 | 83 | # TFS 2012 Local Workspace 84 | $tf/ 85 | 86 | # Guidance Automation Toolkit 87 | *.gpState 88 | 89 | # ReSharper is a .NET coding add-in 90 | _ReSharper*/ 91 | *.[Rr]e[Ss]harper 92 | *.DotSettings.user 93 | 94 | # JustCode is a .NET coding addin-in 95 | .JustCode 96 | 97 | # TeamCity is a build add-in 98 | _TeamCity* 99 | 100 | # DotCover is a Code Coverage Tool 101 | *.dotCover 102 | 103 | # NCrunch 104 | _NCrunch_* 105 | .*crunch*.local.xml 106 | 107 | # MightyMoose 108 | *.mm.* 109 | AutoTest.Net/ 110 | 111 | # Web workbench (sass) 112 | .sass-cache/ 113 | 114 | # Installshield output folder 115 | [Ee]xpress/ 116 | 117 | # DocProject is a documentation generator add-in 118 | DocProject/buildhelp/ 119 | DocProject/Help/*.HxT 120 | DocProject/Help/*.HxC 121 | DocProject/Help/*.hhc 122 | DocProject/Help/*.hhk 123 | DocProject/Help/*.hhp 124 | DocProject/Help/Html2 125 | DocProject/Help/html 126 | 127 | # Click-Once directory 128 | publish/ 129 | 130 | # Publish Web Output 131 | *.[Pp]ublish.xml 132 | *.azurePubxml 133 | # TODO: Comment the next line if you want to checkin your web deploy settings 134 | # but database connection strings (with potential passwords) will be unencrypted 135 | *.pubxml 136 | *.publishproj 137 | 138 | # NuGet Packages 139 | *.nupkg 140 | # The packages folder can be ignored because of Package Restore 141 | **/packages/* 142 | # except build/, which is used as an MSBuild target. 143 | !**/packages/build/ 144 | # Uncomment if necessary however generally it will be regenerated when needed 145 | #!**/packages/repositories.config 146 | 147 | # Windows Azure Build Output 148 | csx/ 149 | *.build.csdef 150 | 151 | # Windows Store app package directory 152 | AppPackages/ 153 | 154 | # Others 155 | *.[Cc]ache 156 | ClientBin/ 157 | [Ss]tyle[Cc]op.* 158 | ~$* 159 | *~ 160 | *.dbmdl 161 | *.dbproj.schemaview 162 | *.pfx 163 | *.publishsettings 164 | node_modules/ 165 | bower_components/ 166 | 167 | # RIA/Silverlight projects 168 | Generated_Code/ 169 | 170 | # Backup & report files from converting an old project file 171 | # to a newer Visual Studio version. Backup files are not needed, 172 | # because we have git ;-) 173 | _UpgradeReport_Files/ 174 | Backup*/ 175 | UpgradeLog*.XML 176 | UpgradeLog*.htm 177 | 178 | # SQL Server files 179 | *.mdf 180 | *.ldf 181 | 182 | # Business Intelligence projects 183 | *.rdl.data 184 | *.bim.layout 185 | *.bim_*.settings 186 | 187 | # Microsoft Fakes 188 | FakesAssemblies/ 189 | 190 | # Node.js Tools for Visual Studio 191 | .ntvs_analysis.dat 192 | 193 | # Visual Studio 6 build log 194 | *.plg 195 | 196 | # Visual Studio 6 workspace options file 197 | *.opt 198 | 199 | # Rider 200 | */.idea/* 201 | .idea/* -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "sub/geta-foundation-core"] 2 | path = sub/geta-foundation-core 3 | url = https://github.com/Geta/geta-foundation-core 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [1.1.0] 6 | 7 | - Refctoring 8 | 9 | ## [1.0.0] 10 | 11 | - Initial release 12 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geta/geta-notfoundhandler/7883e132fec21ad3ac629fd5d79377059175c4dc/images/icon.png -------------------------------------------------------------------------------- /images/redirects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geta/geta-notfoundhandler/7883e132fec21ad3ac629fd5d79377059175c4dc/images/redirects.png -------------------------------------------------------------------------------- /pack.ps1: -------------------------------------------------------------------------------- 1 | $outputDir = ".\package\" 2 | 3 | dotnet pack --output $outputDir 4 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Base/AbstractSortablePageModel.cs: -------------------------------------------------------------------------------- 1 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Models; 2 | using Microsoft.AspNetCore.Mvc.RazorPages; 3 | 4 | namespace Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Base; 5 | 6 | public abstract class AbstractSortablePageModel : PageModel 7 | { 8 | public string SortColumn { get; set; } 9 | public SortDirection? SortDirection { get; set; } 10 | 11 | public void ApplySort(string sortColumn, SortDirection? sortDirection) 12 | { 13 | SortColumn = sortColumn; 14 | SortDirection = sortDirection; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/Card/CardType.cs: -------------------------------------------------------------------------------- 1 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.Card 2 | { 3 | public enum CardType 4 | { 5 | Default, 6 | Success, 7 | Warning 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/Card/CardTypeExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.Card 2 | { 3 | public static class CardTypeExtensions 4 | { 5 | public static string GetCssClass(this CardType cardType) 6 | { 7 | switch (cardType) 8 | { 9 | case CardType.Success: 10 | return "text-white bg-success"; 11 | case CardType.Warning: 12 | return "text-dark bg-warning"; 13 | default: 14 | return string.Empty; 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/Card/CardViewComponent.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.Card 4 | { 5 | public class CardViewComponent : ViewComponent 6 | { 7 | public IViewComponentResult Invoke(string message, CardType cardType) 8 | { 9 | return View(new CardViewModel { Message = message, CardType = cardType }); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/Card/CardViewModel.cs: -------------------------------------------------------------------------------- 1 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.Card 2 | { 3 | public class CardViewModel 4 | { 5 | public string Message { get; set; } 6 | public bool HasMessage => !string.IsNullOrEmpty(Message); 7 | public CardType CardType { get; set; } = CardType.Default; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/Card/Default.cshtml: -------------------------------------------------------------------------------- 1 | @using Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.Card 2 | @model Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.Card.CardViewModel 3 | 4 | 5 | @if (Model.HasMessage) 6 | { 7 |
8 |
9 | @Model.Message 10 |
11 |
12 | } -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/CheckboxReadonly/CheckboxReadonlyViewComponent.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.CheckboxReadonly 4 | { 5 | public class CheckboxReadonlyViewComponent : ViewComponent 6 | { 7 | public IViewComponentResult Invoke(bool isChecked) 8 | { 9 | return View(new CheckboxReadonlyViewModel { IsChecked = isChecked }); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/CheckboxReadonly/CheckboxReadonlyViewModel.cs: -------------------------------------------------------------------------------- 1 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.CheckboxReadonly 2 | { 3 | public class CheckboxReadonlyViewModel 4 | { 5 | public bool IsChecked { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/CheckboxReadonly/Default.cshtml: -------------------------------------------------------------------------------- 1 | @model Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.CheckboxReadonly.CheckboxReadonlyViewModel 2 | 3 | @if (Model.IsChecked) 4 | { 5 | 6 | } 7 | else 8 | { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/Pager/Default.cshtml: -------------------------------------------------------------------------------- 1 | @model Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.Pager.PagerViewModel 2 | 3 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/Pager/PagerViewComponent.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc; 3 | using X.PagedList; 4 | 5 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.Pager 6 | { 7 | public class PagerViewComponent : ViewComponent 8 | { 9 | private readonly IHttpContextAccessor _contextAccessor; 10 | 11 | public PagerViewComponent(IHttpContextAccessor contextAccessor) 12 | { 13 | _contextAccessor = contextAccessor; 14 | } 15 | 16 | public IViewComponentResult Invoke(IPagedList items) 17 | { 18 | var context = _contextAccessor.HttpContext; 19 | return View(new PagerViewModel 20 | { 21 | HasPreviousPage = items.HasPreviousPage, 22 | HasNextPage = items.HasNextPage, 23 | PageNumber = items.PageNumber, 24 | PageCount = items.PageCount, 25 | QueryString = context.Request.QueryString.ToString() 26 | }); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/Pager/PagerViewModel.cs: -------------------------------------------------------------------------------- 1 | using System.Web; 2 | 3 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.Pager 4 | { 5 | public class PagerViewModel 6 | { 7 | public bool HasPreviousPage { get; set; } 8 | public bool HasNextPage { get; set; } 9 | public int PageNumber { get; set; } 10 | public int PageCount { get; set; } 11 | public string QueryString { get; set; } 12 | 13 | public string PageUrl(int page) 14 | { 15 | var qs = HttpUtility.ParseQueryString(QueryString); 16 | qs["page"] = page.ToString(); 17 | return $"?{qs}"; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/SortableHeaderCell/Default.cshtml: -------------------------------------------------------------------------------- 1 | @using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Models 2 | @model Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Components.SortableHeaderCell.SortableHeaderCellViewModel 3 | 4 | 6 | @Model.DisplayName 7 | 8 | @if (Model.IsActive() && Model.GetSortDirection() != null) 9 | { 10 | 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/SortableHeaderCell/SortableHeaderCellViewComponent.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Components.SortableHeaderCell; 5 | 6 | public class SortableHeaderCellViewComponent : ViewComponent 7 | { 8 | private readonly IHttpContextAccessor _contextAccessor; 9 | 10 | public SortableHeaderCellViewComponent(IHttpContextAccessor contextAccessor) 11 | { 12 | _contextAccessor = contextAccessor; 13 | } 14 | 15 | public IViewComponentResult Invoke(string key, string displayName) 16 | { 17 | var context = _contextAccessor.HttpContext; 18 | 19 | return View(new SortableHeaderCellViewModel 20 | { 21 | QueryString = context?.Request.QueryString.ToString(), 22 | Key = key, 23 | DisplayName = displayName 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Components/SortableHeaderCell/SortableHeaderCellViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Web; 3 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Base; 4 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Models; 5 | 6 | namespace Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Components.SortableHeaderCell; 7 | 8 | public class SortableHeaderCellViewModel 9 | { 10 | public string DisplayName { get; set; } 11 | public string Key { get; set; } 12 | public string QueryString { get; set; } 13 | 14 | public SortDirection? GetNextSortDirection() 15 | { 16 | return GetSortDirection() switch 17 | { 18 | null => SortDirection.Ascending, 19 | SortDirection.Ascending => SortDirection.Descending, 20 | SortDirection.Descending => null, 21 | _ => throw new ArgumentOutOfRangeException() 22 | }; 23 | } 24 | 25 | public string GetSortColumn() 26 | { 27 | if (GetNextSortDirection() == null) 28 | { 29 | return null; 30 | } 31 | 32 | return Key; 33 | } 34 | 35 | public string GetSortUrl() 36 | { 37 | var qs = HttpUtility 38 | .ParseQueryString(QueryString); 39 | 40 | qs[nameof(AbstractSortablePageModel.SortColumn)] = GetSortColumn(); 41 | qs[nameof(AbstractSortablePageModel.SortDirection)] = GetNextSortDirection().ToString(); 42 | 43 | return $"?{qs}"; 44 | } 45 | 46 | public bool IsActive() 47 | { 48 | var sortColumn = HttpUtility 49 | .ParseQueryString(QueryString) 50 | .Get(nameof(AbstractSortablePageModel.SortColumn)); 51 | 52 | return !string.IsNullOrEmpty(sortColumn) && sortColumn == Key; 53 | } 54 | 55 | public SortDirection? GetSortDirection() 56 | { 57 | var sortDirection = HttpUtility 58 | .ParseQueryString(QueryString) 59 | .Get(nameof(AbstractSortablePageModel.SortDirection)); 60 | 61 | return Enum.TryParse(sortDirection, out SortDirection sort) ? sort : null; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Deleted.cshtml: -------------------------------------------------------------------------------- 1 | @page "{handler?}" 2 | @using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Components.SortableHeaderCell 3 | @using Geta.NotFoundHandler.Core.Providers.RegexRedirects 4 | @using Microsoft.AspNetCore.Mvc.TagHelpers 5 | @model Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.DeletedModel 6 | 7 | @await Component.InvokeAsync("Card", new { message = Model.Message }) 8 | 9 |
10 |
11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 26 | 34 | 35 | @foreach (var item in Model.Items) 36 | { 37 | 38 | 39 | 47 | 48 | } 49 | 50 |
15 | 16 |
23 | 24 | 25 | 27 |
28 | 32 |
33 |
@item.OldUrl 40 |
41 | 45 |
46 |
51 | @await Component.InvokeAsync(typeof(Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.Pager.PagerViewComponent), new { Model.Items }) 52 |
53 |
-------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Deleted.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Base; 4 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Extensions; 5 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Models; 6 | using Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Models; 7 | using Geta.NotFoundHandler.Core.Redirects; 8 | using Geta.NotFoundHandler.Infrastructure; 9 | using Microsoft.AspNetCore.Authorization; 10 | using Microsoft.AspNetCore.Mvc; 11 | using X.PagedList; 12 | 13 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin; 14 | 15 | [Authorize(Constants.PolicyName)] 16 | public class DeletedModel : AbstractSortablePageModel 17 | { 18 | private readonly IRedirectsService _redirectsService; 19 | 20 | public DeletedModel(IRedirectsService redirectsService) 21 | { 22 | _redirectsService = redirectsService; 23 | } 24 | 25 | public string Message { get; set; } 26 | 27 | public IPagedList Items { get; set; } = Enumerable.Empty().ToPagedList(); 28 | 29 | [BindProperty] 30 | public DeletedRedirectModel DeletedRedirect { get; set; } 31 | 32 | [BindProperty(SupportsGet = true)] 33 | public Paging Paging { get; set; } 34 | 35 | public void OnGet(string sortColumn, SortDirection? sortDirection) 36 | { 37 | ApplySort(sortColumn, sortDirection); 38 | 39 | Load(); 40 | } 41 | 42 | public IActionResult OnPostCreate() 43 | { 44 | if (!ModelState.IsValid) 45 | { 46 | Load(); 47 | return Page(); 48 | } 49 | 50 | _redirectsService.AddDeletedRedirect(DeletedRedirect.OldUrl); 51 | 52 | return RedirectToPage(); 53 | } 54 | 55 | public IActionResult OnPostDelete(string oldUrl) 56 | { 57 | _redirectsService.DeleteByOldUrl(oldUrl); 58 | return RedirectToPage(); 59 | } 60 | 61 | private void Load() 62 | { 63 | var items = FindRedirects().ToPagedList(Paging.PageNumber, Paging.PageSize); 64 | Message = 65 | $"There are currently {items.TotalItemCount} URLs that return a Deleted response. This tells crawlers to remove these URLs from their index."; 66 | Items = items; 67 | } 68 | 69 | private IEnumerable FindRedirects() 70 | { 71 | return _redirectsService 72 | .GetDeleted() 73 | .Sort(SortColumn, SortDirection); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Extensions/EnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Reflection; 4 | using System.Text.RegularExpressions; 5 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Models; 6 | 7 | namespace Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Extensions; 8 | 9 | public static class EnumerableExtensions 10 | { 11 | public static IEnumerable Sort(this IEnumerable list, string propertyName, SortDirection? sortDirection) 12 | { 13 | if (!string.IsNullOrEmpty(propertyName)) 14 | { 15 | var prop = typeof(T).GetProperty(propertyName); 16 | 17 | if (prop != null && sortDirection != null) 18 | { 19 | if (sortDirection == SortDirection.Ascending) 20 | { 21 | list = list.OrderBy(x => GetValue(prop, x)); 22 | } 23 | else 24 | { 25 | list = list.OrderByDescending(x => GetValue(prop, x)); 26 | } 27 | } 28 | } 29 | 30 | return list; 31 | } 32 | 33 | private static object GetValue(PropertyInfo prop, T element) 34 | { 35 | var value = prop.GetValue(element); 36 | 37 | // Value should be IComparable 38 | if (value is Regex regex) 39 | { 40 | return regex.ToString(); 41 | } 42 | 43 | return value; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Extensions/ModelStateExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.ModelBinding; 2 | 3 | namespace Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Extensions; 4 | 5 | public static class ModelStateExtensions 6 | { 7 | public static ModelStateDictionary RemoveNestedKeys(this ModelStateDictionary source, string prefix) 8 | { 9 | foreach (var key in source.Keys) 10 | { 11 | if (key.StartsWith($"{prefix}.")) 12 | { 13 | source.Remove(key); 14 | } 15 | } 16 | 17 | return source; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Ignored.cshtml: -------------------------------------------------------------------------------- 1 | @page "{handler?}" 2 | @using Geta.NotFoundHandler.Core.Redirects 3 | @using Microsoft.AspNetCore.Mvc.TagHelpers 4 | @model Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.IgnoredModel 5 | 6 | @await Component.InvokeAsync("Card", new { message = Model.Message }) 7 | 8 |
9 |
10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 20 | @foreach (var item in Model.Items) 21 | { 22 | 23 | 24 | 32 | 33 | } 34 | 35 |
14 | 15 |
@item.OldUrl 25 |
26 | 30 |
31 |
36 | @await Component.InvokeAsync(typeof(Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Components.Pager.PagerViewComponent), new { Model.Items }) 37 |
38 |
-------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Ignored.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Base; 4 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Extensions; 5 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Models; 6 | using Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Models; 7 | using Geta.NotFoundHandler.Core.Redirects; 8 | using Geta.NotFoundHandler.Infrastructure; 9 | using Microsoft.AspNetCore.Authorization; 10 | using Microsoft.AspNetCore.Mvc; 11 | using X.PagedList; 12 | 13 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin; 14 | 15 | [Authorize(Constants.PolicyName)] 16 | public class IgnoredModel : AbstractSortablePageModel 17 | { 18 | private readonly IRedirectsService _redirectsService; 19 | 20 | public IgnoredModel(IRedirectsService redirectsService) 21 | { 22 | _redirectsService = redirectsService; 23 | } 24 | 25 | public string Message { get; set; } 26 | 27 | public IPagedList Items { get; set; } = Enumerable.Empty().ToPagedList(); 28 | 29 | [BindProperty(SupportsGet = true)] 30 | public Paging Paging { get; set; } 31 | 32 | public void OnGet(string sortColumn, SortDirection? sortDirection) 33 | { 34 | ApplySort(sortColumn, sortDirection); 35 | 36 | Load(); 37 | } 38 | 39 | public IActionResult OnPostUnignore(string oldUrl) 40 | { 41 | _redirectsService.DeleteByOldUrl(oldUrl); 42 | 43 | return RedirectToPage(); 44 | } 45 | 46 | private void Load() 47 | { 48 | var items = FindRedirects().ToPagedList(Paging.PageNumber, Paging.PageSize); 49 | Message = $"There are currently {items.TotalItemCount} ignored suggestions stored."; 50 | Items = items; 51 | } 52 | 53 | private IEnumerable FindRedirects() 54 | { 55 | return _redirectsService 56 | .GetIgnored() 57 | .Sort(SortColumn, SortDirection); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Infrastructure/IFormFileExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Infrastructure 6 | { 7 | public static class IFormFileExtensions 8 | { 9 | public static bool IsXml(this IFormFile file) 10 | { 11 | return FileIsOfType(file, new[] { "text/xml", "application/xml" }, new[] { "xml" }); 12 | } 13 | 14 | public static bool IsCsv(this IFormFile file) 15 | { 16 | return FileIsOfType(file, new[] { "text/csv" }, new[] { "csv" }); 17 | } 18 | 19 | public static bool IsTxt(this IFormFile file) 20 | { 21 | return FileIsOfType(file, new[] { "text/plain" }, new[] { "txt" }); 22 | } 23 | 24 | public static bool FileIsOfType(this IFormFile file, string[] allowedContentTypes, string[] allowedExtensions) 25 | { 26 | var isAllowedContentType = allowedContentTypes.Any( 27 | x => file.ContentType.Equals(x, StringComparison.InvariantCultureIgnoreCase)); 28 | if (isAllowedContentType) 29 | { 30 | return true; 31 | } 32 | 33 | return allowedExtensions.Any(x => file.FileName.EndsWith(x, StringComparison.OrdinalIgnoreCase)); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Models/DeletedRedirectModel.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Models 4 | { 5 | public class DeletedRedirectModel 6 | { 7 | [Required] 8 | public string OldUrl { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Models/Paging.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Models 4 | { 5 | public class Paging 6 | { 7 | public const int DefaultPageSize = 50; 8 | 9 | [FromQuery(Name = "page")] 10 | public int PageNumber { get; set; } = 1; 11 | 12 | [FromQuery(Name = "page-size")] 13 | public int PageSize { get; set; } = DefaultPageSize; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Models/RedirectModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | using Geta.NotFoundHandler.Core.Redirects; 4 | 5 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Models 6 | { 7 | public class RedirectModel 8 | { 9 | public Guid? Id { get; set; } 10 | [Required] 11 | public string OldUrl { get; set; } 12 | [Required] 13 | public string NewUrl { get; set; } 14 | public bool WildCardSkipAppend { get; set; } 15 | public RedirectType RedirectType { get; set; } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Models/RedirectsRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages; 4 | 5 | public class RedirectsRequest 6 | { 7 | public string Query { get; set; } 8 | public int? PageNumber { get; set; } 9 | public Guid? Id { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Models/RegexRedirectModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.ComponentModel.DataAnnotations; 6 | 7 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Models; 8 | 9 | public class RegexRedirectModel 10 | { 11 | public Guid? Id { get; set; } 12 | [Required] 13 | [Display(Name = "Old URL Regex")] 14 | public string OldUrlRegex { get; set; } 15 | [Required] 16 | [Display(Name = "New URL Format")] 17 | public string NewUrlFormat { get; set; } 18 | [Required] 19 | [Display(Name = "Order Number")] 20 | public int OrderNumber { get; set; } 21 | } 22 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Models/SortDirection.cs: -------------------------------------------------------------------------------- 1 | namespace Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Models; 2 | 3 | public enum SortDirection 4 | { 5 | Ascending, 6 | Descending 7 | } 8 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Models/SuggestionRedirectModel.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.ComponentModel.DataAnnotations; 3 | using Geta.NotFoundHandler.Core.Suggestions; 4 | 5 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Models 6 | { 7 | public class SuggestionRedirectModel 8 | { 9 | [Required] 10 | public string OldUrl { get; set; } 11 | [Required] 12 | public string NewUrl { get; set; } 13 | public int Count { get; set; } 14 | public ICollection Referers { get; set; } = new List(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Regex.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Base; 5 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Extensions; 6 | using Geta.NotFoundHandler.Admin.Areas.GetaNotFoundHandlerAdmin.Pages.Models; 7 | using Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Models; 8 | using Geta.NotFoundHandler.Core.Providers.RegexRedirects; 9 | using Geta.NotFoundHandler.Data; 10 | using Geta.NotFoundHandler.Infrastructure; 11 | using Microsoft.AspNetCore.Authorization; 12 | using Microsoft.AspNetCore.Mvc; 13 | 14 | namespace Geta.NotFoundHandler.Admin.Areas.Geta.NotFoundHandler.Admin; 15 | 16 | [Authorize(Constants.PolicyName)] 17 | public class RegexModel : AbstractSortablePageModel 18 | { 19 | private readonly IRegexRedirectLoader _redirectLoader; 20 | private readonly IRegexRedirectsService _regexRedirectsService; 21 | 22 | public RegexModel( 23 | IRegexRedirectsService regexRedirectsService, 24 | IRegexRedirectLoader redirectLoader) 25 | { 26 | _regexRedirectsService = regexRedirectsService; 27 | _redirectLoader = redirectLoader; 28 | } 29 | 30 | public string Message { get; set; } 31 | 32 | public IEnumerable Items { get; set; } = Enumerable.Empty(); 33 | 34 | [BindProperty(Name = nameof(RegexRedirect))] 35 | public RegexRedirectModel RegexRedirect { get; set; } 36 | 37 | [BindProperty(Name = nameof(EditRedirect))] 38 | public RegexRedirectModel EditRedirect { get; set; } 39 | 40 | public void OnGet(string sortColumn, SortDirection? sortDirection) 41 | { 42 | ApplySort(sortColumn, sortDirection); 43 | 44 | Load(); 45 | } 46 | 47 | public IActionResult OnPostCreate() 48 | { 49 | ModelState.RemoveNestedKeys(nameof(EditRedirect)); 50 | 51 | if (ModelState.IsValid) 52 | { 53 | _regexRedirectsService.Create(RegexRedirect.OldUrlRegex, RegexRedirect.NewUrlFormat, RegexRedirect.OrderNumber); 54 | 55 | return RedirectToPage(); 56 | } 57 | 58 | Load(); 59 | return Page(); 60 | } 61 | 62 | public IActionResult OnPostDelete(Guid id) 63 | { 64 | _regexRedirectsService.Delete(id); 65 | 66 | return RedirectToPage(); 67 | } 68 | 69 | public IActionResult OnPostUpdate() 70 | { 71 | ModelState.RemoveNestedKeys(nameof(RegexRedirect)); 72 | 73 | if (ModelState.IsValid && 74 | EditRedirect.Id != null) 75 | { 76 | _regexRedirectsService.Update(EditRedirect.Id.Value, 77 | EditRedirect.OldUrlRegex, 78 | EditRedirect.NewUrlFormat, 79 | EditRedirect.OrderNumber); 80 | return RedirectToPage(); 81 | } 82 | 83 | Load(); 84 | 85 | return Page(); 86 | } 87 | 88 | private void Load() 89 | { 90 | var items = FindRedirects(); 91 | Message = $"There are currently stored {items.Count()} Regex redirects."; 92 | Items = items; 93 | RegexRedirect = new RegexRedirectModel { OrderNumber = items.Select(x => x.OrderNumber).DefaultIfEmpty().Max() + 1 }; 94 | } 95 | 96 | private IEnumerable FindRedirects() 97 | { 98 | return _redirectLoader 99 | .GetAll() 100 | .Sort(SortColumn, SortDirection); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Shared/_Logo.cshtml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/Suggestions.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin.Models; 4 | using Geta.NotFoundHandler.Core.Suggestions; 5 | using Geta.NotFoundHandler.Infrastructure; 6 | using Microsoft.AspNetCore.Authorization; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.AspNetCore.Mvc.RazorPages; 9 | using X.PagedList; 10 | 11 | namespace Geta.NotFoundHandler.Admin.Pages.Geta.NotFoundHandler.Admin; 12 | 13 | [Authorize(Constants.PolicyName)] 14 | public class SuggestionsModel : PageModel 15 | { 16 | private readonly ISuggestionService _suggestionService; 17 | 18 | public SuggestionsModel(ISuggestionService suggestionService) 19 | { 20 | _suggestionService = suggestionService; 21 | } 22 | 23 | public string Message { get; set; } 24 | 25 | public IPagedList Items { get; set; } = Enumerable.Empty().ToPagedList(); 26 | 27 | [BindProperty(SupportsGet = true)] 28 | public Paging Paging { get; set; } 29 | 30 | public void OnGet() 31 | { 32 | Load(); 33 | } 34 | 35 | public IActionResult OnPostCreate(Dictionary items) 36 | { 37 | if (!ModelState.IsValid) 38 | { 39 | Load(); 40 | return Page(); 41 | } 42 | 43 | var item = items.First().Value; 44 | 45 | _suggestionService.AddRedirect(new SuggestionRedirect(item.OldUrl, item.NewUrl)); 46 | 47 | return RedirectToPage(); 48 | } 49 | 50 | public IActionResult OnPostIgnore(string oldUrl) 51 | { 52 | _suggestionService.IgnoreSuggestion(oldUrl); 53 | 54 | return RedirectToPage(); 55 | } 56 | 57 | private void Load() 58 | { 59 | var summaries = _suggestionService.GetSummaries(Paging.PageNumber, Paging.PageSize); 60 | var redirectModels = summaries.Select(x => new SuggestionRedirectModel 61 | { 62 | OldUrl = x.OldUrl, 63 | Count = x.Count, 64 | Referers = x.Referers 65 | }); 66 | 67 | Message = $"Based on the logged 404 errors, there are {summaries.TotalItemCount} custom redirect suggestions."; 68 | Items = new StaticPagedList(redirectModels, summaries); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 2 | @addTagHelper *, Geta.NotFoundHandler.Admin -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Areas/GetaNotFoundHandlerAdmin/Pages/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "Shared/_Layout"; 3 | } -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/Geta.NotFoundHandler.Admin.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | true 6 | Geta.NotFoundHandler.Admin 7 | Admin UI for NotFound Handler for ASP.NET Core and EPiServer 8 | Geta Digital 9 | Geta Digital 10 | Apache-2.0 11 | https://github.com/Geta/geta-notfoundhandler 12 | icon.png 13 | https://cdn.geta.no/opensource/icons/Geta-logo-3.png 14 | false 15 | This library contains an Admin user interface for a NotFound handler for your ASP.NET Core or EPiServer project. 16 | https://github.com/Geta/geta-notfoundhandler/blob/master/CHANGELOG.md 17 | 404 NotFound 404Error Handler Geta Redirect 18 | https://github.com/Geta/geta-notfoundhandler.git 19 | /_content/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/wwwroot/GetaNotFoundHandlerAdmin/css/dashboard.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Icons 3 | */ 4 | 5 | .feather { 6 | width: 16px; 7 | height: 16px; 8 | margin-bottom: 3px; 9 | } 10 | 11 | /* 12 | * Sidebar 13 | */ 14 | 15 | .sidebar { 16 | position: fixed; 17 | top: 0; 18 | /* rtl:raw: 19 | right: 0; 20 | */ 21 | bottom: 0; 22 | /* rtl:remove */ 23 | left: 0; 24 | z-index: 100; /* Behind the navbar */ 25 | padding: 48px 0 0; /* Height of navbar */ 26 | box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); 27 | } 28 | 29 | @media (max-width: 767.98px) { 30 | .sidebar { 31 | top: 5rem; 32 | } 33 | } 34 | 35 | .sidebar-sticky { 36 | position: relative; 37 | top: 0; 38 | height: calc(100vh - 48px); 39 | padding-top: .5rem; 40 | overflow-x: hidden; 41 | overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ 42 | } 43 | 44 | .sidebar .nav-link { 45 | font-weight: 500; 46 | color: #333; 47 | } 48 | 49 | .sidebar .nav-link .feather { 50 | margin-right: 4px; 51 | color: #727272; 52 | } 53 | 54 | .sidebar .nav-link.active { 55 | color: #007bff; 56 | } 57 | 58 | .sidebar .nav-link:hover .feather, 59 | .sidebar .nav-link.active .feather { 60 | color: inherit; 61 | } 62 | 63 | .sidebar-heading { 64 | font-size: .75rem; 65 | text-transform: uppercase; 66 | } 67 | 68 | /* 69 | * Navbar 70 | */ 71 | 72 | .geta-logo svg { 73 | width: 60px; 74 | } 75 | 76 | .geta-logo-prefix { 77 | color: white; 78 | font-size: 12px; 79 | } 80 | 81 | .version { 82 | color: white; 83 | font-size: 0.6em; 84 | } 85 | 86 | /* 87 | * Search 88 | */ 89 | 90 | .search-container { 91 | margin-top: 15px; 92 | } 93 | 94 | .search-container .search-button { 95 | width: 120px; 96 | } 97 | 98 | /* 99 | * Form 100 | */ 101 | 102 | .input-number { 103 | width: 50px; 104 | } 105 | 106 | .input-inline { 107 | margin: 0 5px 0 5px; 108 | } 109 | 110 | .administer-button { 111 | width: 120px; 112 | } 113 | 114 | .input-file { 115 | width: 450px; 116 | } 117 | 118 | /* 119 | * Table 120 | */ 121 | 122 | td { 123 | word-break: break-all; 124 | } 125 | 126 | .edit-modal { 127 | max-height: 600px !important; 128 | } 129 | 130 | .referrers-modal { 131 | max-height: 350px !important; 132 | } 133 | 134 | .pagination-spacing { 135 | margin-top: 5rem !important; 136 | } -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Admin/wwwroot/GetaNotFoundHandlerAdmin/js/dashboard.js: -------------------------------------------------------------------------------- 1 | /* globals feather:false */ 2 | 3 | (function () { 4 | 'use strict'; 5 | 6 | feather.replace(); 7 | 8 | function clearInput() { 9 | var initiators = document.querySelectorAll('[data-clear]'); 10 | initiators.forEach(function (initiator) { 11 | initiator.addEventListener('click', 12 | function (e) { 13 | var target = e.currentTarget; 14 | var selector = target.getAttribute('data-clear'); 15 | var input = document.querySelector(selector); 16 | input.value = ''; 17 | }); 18 | }); 19 | } 20 | 21 | function confirmSubmit() { 22 | var initiators = document.querySelectorAll('[data-confirm]'); 23 | initiators.forEach(function (initiator) { 24 | var form = initiator.form; 25 | form.addEventListener('submit', 26 | function (e) { 27 | e.preventDefault(); 28 | 29 | var message = initiator.getAttribute('data-confirm'); 30 | if (confirm(message)) { 31 | form.action = initiator.formAction; 32 | form.submit(); 33 | } 34 | }); 35 | }); 36 | } 37 | 38 | function adjustModalPosition() { 39 | var modalTriggers = document.querySelectorAll('.modal-trigger[data-bs-target]'); 40 | modalTriggers.forEach(function (modalTrigger) { 41 | modalTrigger.addEventListener('click', function () { 42 | var dialogSelector = modalTrigger.dataset.bsTarget + " .modal-dialog"; 43 | 44 | var modalDialog = document.querySelector(dialogSelector); 45 | 46 | if (!modalDialog) { return; } 47 | 48 | modalDialog.style = "position: fixed;" + 49 | "top: " + modalTrigger.getBoundingClientRect().top + "px;" + 50 | "left: 50%;" + 51 | "min-width: 500px;" + 52 | "transform: translate(-50%, -50%);"; 53 | }); 54 | }); 55 | } 56 | 57 | clearInput(); 58 | confirmSubmit(); 59 | adjustModalPosition(); 60 | })() 61 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely.Commerce/AutomaticRedirects/CommerceContentKeyProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using EPiServer.Core; 5 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 6 | using Mediachase.Commerce.Catalog; 7 | 8 | namespace Geta.NotFoundHandler.Optimizely.Commerce.AutomaticRedirects 9 | { 10 | public class CommerceContentKeyProvider : IContentKeyProvider 11 | { 12 | private readonly ReferenceConverter _referenceConverter; 13 | 14 | public CommerceContentKeyProvider(ReferenceConverter referenceConverter) 15 | { 16 | _referenceConverter = referenceConverter; 17 | } 18 | 19 | public ContentKeyResult GetContentKey(ContentReference contentLink) 20 | { 21 | if (ContentReference.IsNullOrEmpty(contentLink) || contentLink.ProviderName != "CatalogContent") 22 | { 23 | return ContentKeyResult.Empty; 24 | } 25 | 26 | var contentType = _referenceConverter.GetContentType(contentLink); 27 | if (contentType == CatalogContentType.Root || contentType == CatalogContentType.Catalog) 28 | { 29 | return ContentKeyResult.Empty; 30 | } 31 | 32 | var code = _referenceConverter.GetCode(contentLink); 33 | if (string.IsNullOrWhiteSpace(code)) 34 | { 35 | return ContentKeyResult.Empty; 36 | } 37 | 38 | return new ContentKeyResult($"{code}--{contentLink.ProviderName}"); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely.Commerce/AutomaticRedirects/CommerceContentLinkProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using EPiServer; 7 | using EPiServer.Core; 8 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 9 | using Mediachase.Commerce.Catalog; 10 | 11 | namespace Geta.NotFoundHandler.Optimizely.Commerce.AutomaticRedirects 12 | { 13 | public class CommerceContentLinkProvider : IContentLinkProvider 14 | { 15 | private readonly ReferenceConverter _referenceConverter; 16 | private readonly IContentLoader _contentLoader; 17 | 18 | public CommerceContentLinkProvider(ReferenceConverter referenceConverter, IContentLoader contentLoader) 19 | { 20 | _referenceConverter = referenceConverter; 21 | _contentLoader = contentLoader; 22 | } 23 | 24 | public IEnumerable GetAllLinks() 25 | { 26 | return _contentLoader.GetDescendents(_referenceConverter.GetRootLink()).ToList(); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely.Commerce/AutomaticRedirects/EntryContentUrlProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using EPiServer.Cms.Shell; 7 | using EPiServer.Commerce.Catalog.ContentTypes; 8 | using EPiServer.Commerce.Catalog.Linking; 9 | using EPiServer.Core; 10 | using EPiServer.Web.Routing; 11 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 12 | 13 | namespace Geta.NotFoundHandler.Optimizely.Commerce.AutomaticRedirects 14 | { 15 | public class EntryContentUrlProvider : IContentUrlProvider 16 | { 17 | private readonly IUrlResolver _urlResolver; 18 | private readonly IRelationRepository _relationRepository; 19 | 20 | public EntryContentUrlProvider(IUrlResolver urlResolver, IRelationRepository relationRepository) 21 | { 22 | _urlResolver = urlResolver; 23 | _relationRepository = relationRepository; 24 | } 25 | 26 | public IEnumerable GetUrls(IContent content) 27 | { 28 | if (!CanHandle(content)) 29 | { 30 | return Enumerable.Empty(); 31 | } 32 | 33 | var entry = (EntryContentBase)content; 34 | 35 | return GetNodeContentUrls(entry); 36 | } 37 | 38 | public bool CanHandle(IContent content) 39 | { 40 | return content is EntryContentBase; 41 | } 42 | 43 | private IEnumerable GetNodeContentUrls(EntryContentBase entry) 44 | { 45 | var language = entry.LanguageBranch(); 46 | 47 | var parentsLinks = _relationRepository 48 | .GetParents(entry.ContentLink) 49 | .ToList(); 50 | 51 | foreach (var nodeParent in parentsLinks) 52 | { 53 | yield return new TypedUrl 54 | { 55 | Url = $"{_urlResolver.GetUrl(nodeParent.Parent, language)}/{entry.RouteSegment}", 56 | Type = nodeParent.IsPrimary ? UrlType.Primary : UrlType.Secondary, 57 | Language = language 58 | }; 59 | } 60 | 61 | yield return new TypedUrl { Url = $"/{entry.SeoUri}", Type = UrlType.Seo, Language = language}; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely.Commerce/AutomaticRedirects/NodeContentUrlProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using EPiServer.Cms.Shell; 7 | using EPiServer.Commerce.Catalog.ContentTypes; 8 | using EPiServer.Commerce.Catalog.Linking; 9 | using EPiServer.Core; 10 | using EPiServer.Web.Routing; 11 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 12 | 13 | namespace Geta.NotFoundHandler.Optimizely.Commerce.AutomaticRedirects 14 | { 15 | public class NodeContentUrlProvider : IContentUrlProvider 16 | { 17 | private readonly IUrlResolver _urlResolver; 18 | private readonly IRelationRepository _relationRepository; 19 | 20 | public NodeContentUrlProvider(IUrlResolver urlResolver, IRelationRepository relationRepository) 21 | { 22 | _urlResolver = urlResolver; 23 | _relationRepository = relationRepository; 24 | } 25 | 26 | public IEnumerable GetUrls(IContent content) 27 | { 28 | if (!CanHandle(content)) 29 | { 30 | return Enumerable.Empty(); 31 | } 32 | 33 | var node = (NodeContent)content; 34 | 35 | return GetNodeContentUrls(node); 36 | } 37 | 38 | public bool CanHandle(IContent content) 39 | { 40 | return content is NodeContent; 41 | } 42 | 43 | private IEnumerable GetNodeContentUrls(NodeContent node) 44 | { 45 | var language = node.LanguageBranch(); 46 | 47 | var parentsLinks = _relationRepository 48 | .GetParents(node.ContentLink) 49 | .Select(x => x.Parent) 50 | .ToList(); 51 | 52 | // primary parent node is not returned from _relationRepository.GetParents for nodes while it is for entries 53 | parentsLinks.Insert(0, node.ParentLink); 54 | parentsLinks = parentsLinks.Distinct().ToList(); 55 | 56 | foreach (var parentLink in parentsLinks) 57 | { 58 | yield return new TypedUrl 59 | { 60 | Url = $"{_urlResolver.GetUrl(parentLink, language)}/{node.RouteSegment}", 61 | Type = parentLink == node.ParentLink ? UrlType.Primary : UrlType.Secondary, 62 | Language = language 63 | }; 64 | } 65 | 66 | yield return new TypedUrl { Url = $"/{node.SeoUri}", Type = UrlType.Seo, Language = language}; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely.Commerce/Geta.NotFoundHandler.Optimizely.Commerce.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | Geta.NotFoundHandler.Optimizely.Commerce 6 | NotFound handler Admin UI integration Optimizely Commerce 7 | Geta Digital 8 | Geta Digital 9 | Apache-2.0 10 | https://github.com/Geta/geta-notfoundhandler 11 | icon.png 12 | https://cdn.geta.no/opensource/icons/Geta-logo-3.png 13 | false 14 | This library contains a NotFound handler Admin user interface integration in an Optimizely Commerce project. 15 | https://github.com/Geta/geta-notfoundhandler/blob/master/CHANGELOG.md 16 | 404 NotFound 404Error Handler Geta Redirect 17 | https://github.com/Geta/geta-notfoundhandler.git 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely.Commerce/Infrastructure/Configuration/OptimizelyNotFoundHandlerOptionsExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using Geta.NotFoundHandler.Optimizely.Commerce.AutomaticRedirects; 5 | using Geta.NotFoundHandler.Optimizely.Infrastructure.Configuration; 6 | 7 | namespace Geta.NotFoundHandler.Optimizely.Commerce.Infrastructure.Configuration 8 | { 9 | public static class OptimizelyNotFoundHandlerOptionsExtensions 10 | { 11 | public static OptimizelyNotFoundHandlerOptions AddOptimizelyCommerceProviders( 12 | this OptimizelyNotFoundHandlerOptions options) 13 | { 14 | options.AddContentKeyProviders(); 15 | options.AddContentLinkProviders(); 16 | options.AddContentUrlProviders(); 17 | options.AddContentUrlProviders(); 18 | 19 | return options; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Constants.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Optimizely 5 | { 6 | public static class Constants 7 | { 8 | public const string ModuleName = "Geta.NotFoundHandler.Optimizely"; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/ContainerController.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace Geta.NotFoundHandler.Optimizely 8 | { 9 | public class ContainerController : Controller 10 | { 11 | [Authorize(Policy = NotFoundHandler.Infrastructure.Constants.PolicyName)] 12 | [HttpGet] 13 | public IActionResult Index() 14 | { 15 | return View(); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/ChannelMovedContentRegistratorQueue.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using System.Threading; 6 | using System.Threading.Channels; 7 | using EPiServer.Core; 8 | 9 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 10 | { 11 | public class ChannelMovedContentRegistratorQueue : IMovedContentRegistratorQueue 12 | { 13 | private static readonly Channel Buffer = Channel.CreateUnbounded( 14 | new UnboundedChannelOptions 15 | { 16 | AllowSynchronousContinuations = false, 17 | SingleWriter = false, 18 | SingleReader = true 19 | }); 20 | 21 | public virtual IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken = default) 22 | { 23 | return Buffer.Reader.ReadAllAsync(cancellationToken); 24 | } 25 | 26 | public virtual void Enqueue(ContentReference contentLink) 27 | { 28 | Buffer.Writer.TryWrite(contentLink); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/CmsContentKeyProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using EPiServer.Core; 5 | 6 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 7 | { 8 | public class CmsContentKeyProvider : IContentKeyProvider 9 | { 10 | public ContentKeyResult GetContentKey(ContentReference contentLink) 11 | { 12 | if (ContentReference.IsNullOrEmpty(contentLink) || contentLink.ProviderName != null || contentLink.IsExternalProvider) 13 | { 14 | return ContentKeyResult.Empty; 15 | } 16 | 17 | var key = contentLink.ToReferenceWithoutVersion().ToString(); 18 | return new ContentKeyResult(key); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/CmsContentLinkProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using EPiServer; 7 | using EPiServer.Core; 8 | using EPiServer.Web; 9 | 10 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 11 | { 12 | public class CmsContentLinkProvider : IContentLinkProvider 13 | { 14 | private readonly ISiteDefinitionRepository _siteDefinitionRepository; 15 | private readonly IContentLoader _contentLoader; 16 | 17 | public CmsContentLinkProvider(ISiteDefinitionRepository siteDefinitionRepository, IContentLoader contentLoader) 18 | { 19 | _siteDefinitionRepository = siteDefinitionRepository; 20 | _contentLoader = contentLoader; 21 | } 22 | 23 | public IEnumerable GetAllLinks() 24 | { 25 | var allSites = _siteDefinitionRepository.List(); 26 | return allSites.SelectMany(site => _contentLoader.GetDescendents(site.StartPage)); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/CmsContentUrlProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Globalization; 7 | using System.Linq; 8 | using EPiServer; 9 | using EPiServer.Cms.Shell; 10 | using EPiServer.Core; 11 | using EPiServer.Web.Routing; 12 | 13 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 14 | { 15 | public class CmsContentUrlProvider : IContentUrlProvider 16 | { 17 | private readonly IContentLoader _contentLoader; 18 | private readonly IContentVersionRepository _contentVersionRepository; 19 | private readonly IUrlResolver _urlResolver; 20 | 21 | public CmsContentUrlProvider( 22 | IContentLoader contentLoader, 23 | IContentVersionRepository contentVersionRepository, 24 | IUrlResolver urlResolver) 25 | { 26 | _contentLoader = contentLoader; 27 | _contentVersionRepository = contentVersionRepository; 28 | _urlResolver = urlResolver; 29 | } 30 | 31 | public IEnumerable GetUrls(IContent content) 32 | { 33 | if (!CanHandle(content)) 34 | { 35 | return Enumerable.Empty(); 36 | } 37 | 38 | var page = (PageData)content; 39 | if (page.StopPublish <= DateTime.UtcNow) 40 | { 41 | return Enumerable.Empty(); 42 | } 43 | 44 | return GetPageUrls(page); 45 | } 46 | 47 | public bool CanHandle(IContent content) 48 | { 49 | return content is PageData; 50 | } 51 | 52 | private IEnumerable GetPageUrls(PageData page) 53 | { 54 | var language = page.LanguageBranch(); 55 | 56 | return new List { new() { Url = GetPageUrl(page, language), Type = UrlType.Primary, Language = language} }; 57 | } 58 | 59 | private string GetPageUrl(PageData page, string language) 60 | { 61 | if (page.LinkType == PageShortcutType.External || page.LinkType == PageShortcutType.Shortcut) 62 | { 63 | var lastPublishedVersion = _contentVersionRepository.LoadPublished(page.ParentLink, language); 64 | 65 | if (lastPublishedVersion != null) 66 | { 67 | var parent = _contentLoader.Get(lastPublishedVersion.ContentLink, new CultureInfo(language)); 68 | if (parent is PageData parentPage) 69 | { 70 | var parentUrl = GetPageUrl(parentPage, language); 71 | return $"{parentUrl}/{page.URLSegment}/"; 72 | } 73 | } 74 | 75 | return "/"; 76 | } 77 | 78 | return _urlResolver.GetUrl(page.ContentLink, language); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/ContentKeyGenerator.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using EPiServer.Core; 7 | 8 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 9 | { 10 | public class ContentKeyGenerator 11 | { 12 | private readonly IEnumerable _contentKeyProviders; 13 | 14 | public ContentKeyGenerator(IEnumerable contentKeyProviders) 15 | { 16 | _contentKeyProviders = contentKeyProviders; 17 | } 18 | 19 | public virtual ContentKeyResult GetContentKey(ContentReference contentLink) 20 | { 21 | if (ContentReference.IsNullOrEmpty(contentLink)) 22 | { 23 | return ContentKeyResult.Empty; 24 | } 25 | 26 | return _contentKeyProviders 27 | .Select(provider => provider.GetContentKey(contentLink)) 28 | .FirstOrDefault(result => result.HasValue) 29 | ?? ContentKeyResult.Empty; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/ContentKeyResult.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 5 | { 6 | public class ContentKeyResult 7 | { 8 | public static ContentKeyResult Empty { get; } = new(); 9 | 10 | public string Key { get; } 11 | public bool HasValue { get; } 12 | 13 | private ContentKeyResult() { } 14 | 15 | public ContentKeyResult(string key) 16 | { 17 | Key = key; 18 | HasValue = true; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/ContentLinkLoader.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using EPiServer.Core; 7 | 8 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 9 | { 10 | public class ContentLinkLoader 11 | { 12 | private readonly IEnumerable _contentLinkProviders; 13 | 14 | public ContentLinkLoader(IEnumerable contentLinkProviders) 15 | { 16 | _contentLinkProviders = contentLinkProviders; 17 | } 18 | 19 | public virtual IEnumerable GetAllLinks() 20 | { 21 | return _contentLinkProviders.SelectMany(provider => provider.GetAllLinks()).Distinct().ToList(); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/ContentUrlHistory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | 7 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 8 | { 9 | public class ContentUrlHistory 10 | { 11 | public Guid Id { get; set; } 12 | public string ContentKey { get; set; } 13 | public DateTime CreatedUtc { get; set; } 14 | public ICollection Urls { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/ContentUrlHistoryEvents.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Linq; 5 | using EPiServer; 6 | using EPiServer.Core; 7 | using Geta.NotFoundHandler.Optimizely.Infrastructure.Configuration; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 11 | { 12 | public class ContentUrlHistoryEvents 13 | { 14 | private readonly IContentEvents _contentEvents; 15 | private readonly ContentUrlIndexer _contentUrlIndexer; 16 | private readonly IMovedContentRegistratorQueue _movedContentRegistratorQueue; 17 | private readonly OptimizelyNotFoundHandlerOptions _configuration; 18 | 19 | public ContentUrlHistoryEvents( 20 | IContentEvents contentEvents, 21 | IOptions options, 22 | ContentUrlIndexer contentUrlIndexer, 23 | IMovedContentRegistratorQueue movedContentRegistratorQueue) 24 | { 25 | _contentEvents = contentEvents; 26 | _contentUrlIndexer = contentUrlIndexer; 27 | _movedContentRegistratorQueue = movedContentRegistratorQueue; 28 | _configuration = options.Value; 29 | } 30 | 31 | public void Initialize() 32 | { 33 | if (_configuration.AutomaticRedirectsEnabled) 34 | { 35 | _contentEvents.MovedContent += OnMovedContent; 36 | _contentEvents.PublishedContent += OnPublishedContent; 37 | } 38 | } 39 | 40 | private void OnMovedContent(object sender, ContentEventArgs e) 41 | { 42 | if (e is MoveContentEventArgs me 43 | && new[] { me.OriginalParent, me.TargetLink }.Any(x => x.CompareToIgnoreWorkID(ContentReference.WasteBasket))) 44 | { 45 | return; 46 | } 47 | _movedContentRegistratorQueue.Enqueue(e.ContentLink); 48 | } 49 | 50 | private void OnPublishedContent(object sender, ContentEventArgs e) 51 | { 52 | _contentUrlIndexer.IndexContentUrls(e.ContentLink); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/ContentUrlIndexer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Linq; 5 | using EPiServer.Core; 6 | using Geta.NotFoundHandler.Data; 7 | 8 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 9 | { 10 | public class ContentUrlIndexer 11 | { 12 | private readonly ContentKeyGenerator _contentKeyGenerator; 13 | private readonly ContentUrlLoader _contentUrlLoader; 14 | private readonly IRepository _contentUrlHistoryRepository; 15 | private readonly IContentUrlHistoryLoader _contentUrlHistoryLoader; 16 | 17 | public ContentUrlIndexer( 18 | ContentKeyGenerator contentKeyGenerator, 19 | ContentUrlLoader contentUrlLoader, 20 | IRepository contentUrlHistoryRepository, 21 | IContentUrlHistoryLoader contentUrlHistoryLoader) 22 | { 23 | _contentKeyGenerator = contentKeyGenerator; 24 | _contentUrlLoader = contentUrlLoader; 25 | _contentUrlHistoryRepository = contentUrlHistoryRepository; 26 | _contentUrlHistoryLoader = contentUrlHistoryLoader; 27 | } 28 | 29 | public virtual void IndexContentUrls(ContentReference contentLink) 30 | { 31 | var keyResult = _contentKeyGenerator.GetContentKey(contentLink); 32 | if (!keyResult.HasValue) return; 33 | 34 | var urls = _contentUrlLoader.GetUrls(contentLink).ToList(); 35 | var history = new ContentUrlHistory { ContentKey = keyResult.Key, Urls = urls }; 36 | 37 | if (!_contentUrlHistoryLoader.IsRegistered(history)) 38 | { 39 | _contentUrlHistoryRepository.Save(history); 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/DefaultAutomaticRedirectsService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using Geta.NotFoundHandler.Core.Redirects; 7 | 8 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 9 | { 10 | public class DefaultAutomaticRedirectsService : IAutomaticRedirectsService 11 | { 12 | private readonly IRedirectsService _redirectsService; 13 | private readonly RedirectBuilder _redirectBuilder; 14 | 15 | public DefaultAutomaticRedirectsService( 16 | IRedirectsService redirectsService, 17 | RedirectBuilder redirectBuilder) 18 | { 19 | _redirectsService = redirectsService; 20 | _redirectBuilder = redirectBuilder; 21 | } 22 | 23 | public void CreateRedirects(IReadOnlyCollection histories) 24 | { 25 | var redirects = _redirectBuilder.CreateRedirects(histories).ToList(); 26 | _redirectsService.AddOrUpdate(redirects); 27 | var urlsToRemove = redirects.Where(x => x.NewUrl == x.OldUrl).Select(x => x.OldUrl); 28 | _redirectsService.DeleteByOldUrl(urlsToRemove); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/IAutomaticRedirectsService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | 6 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 7 | { 8 | public interface IAutomaticRedirectsService 9 | { 10 | void CreateRedirects(IReadOnlyCollection histories); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/IContentKeyProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using EPiServer.Core; 5 | 6 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 7 | { 8 | public interface IContentKeyProvider 9 | { 10 | ContentKeyResult GetContentKey(ContentReference contentLink); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/IContentLinkProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using EPiServer.Core; 6 | 7 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 8 | { 9 | public interface IContentLinkProvider 10 | { 11 | IEnumerable GetAllLinks(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/IContentUrlHistoryLoader.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | 6 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 7 | { 8 | public interface IContentUrlHistoryLoader 9 | { 10 | bool IsRegistered(ContentUrlHistory entity); 11 | IEnumerable<(string contentKey, IReadOnlyCollection histories)> GetAllMoved(); 12 | IReadOnlyCollection GetMoved(string contentKey); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/IContentUrlProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using EPiServer.Core; 6 | 7 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 8 | { 9 | public interface IContentUrlProvider 10 | { 11 | IEnumerable GetUrls(IContent content); 12 | bool CanHandle(IContent content); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/IMovedContentRegistratorQueue.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using EPiServer.Core; 5 | 6 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 7 | { 8 | public interface IMovedContentRegistratorQueue 9 | { 10 | void Enqueue(ContentReference contentLink); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/IndexContentUrlsJob.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Linq; 6 | using EPiServer.PlugIn; 7 | using EPiServer.Scheduler; 8 | using Geta.NotFoundHandler.Optimizely.Infrastructure; 9 | 10 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 11 | { 12 | [ScheduledPlugIn(DisplayName = "[Geta NotFoundHandler] Index content URLs", 13 | GUID = "53C743AE-E152-497A-A7E5-7E30F4B5B321", 14 | SortIndex = 5555)] 15 | public class IndexContentUrlsJob : ScheduledJobBase 16 | { 17 | private bool _stopped; 18 | 19 | private readonly ContentUrlIndexer _contentUrlIndexer; 20 | private readonly ContentLinkLoader _contentLinkLoader; 21 | private readonly JobStatusLogger _jobStatusLogger; 22 | 23 | public IndexContentUrlsJob(ContentUrlIndexer contentUrlIndexer, ContentLinkLoader contentLinkLoader) 24 | { 25 | _contentUrlIndexer = contentUrlIndexer; 26 | _contentLinkLoader = contentLinkLoader; 27 | _jobStatusLogger = new JobStatusLogger(OnStatusChanged); 28 | 29 | IsStoppable = true; 30 | } 31 | 32 | public override string Execute() 33 | { 34 | var contentLinks = _contentLinkLoader.GetAllLinks().ToList(); 35 | 36 | var totalCount = contentLinks.Count; 37 | var successCount = 0; 38 | var failedCount = 0; 39 | var currentCount = 0; 40 | 41 | _jobStatusLogger.LogWithStatus($"In total will process unique references: {totalCount}"); 42 | 43 | foreach (var contentLink in contentLinks) 44 | { 45 | if (_stopped) 46 | { 47 | _jobStatusLogger.Log( 48 | $"Job was stopped, successful references before stopped: {successCount} out of total {totalCount} references"); 49 | return _jobStatusLogger.ToString(); 50 | } 51 | 52 | currentCount++; 53 | 54 | try 55 | { 56 | _contentUrlIndexer.IndexContentUrls(contentLink); 57 | 58 | successCount++; 59 | } 60 | catch (Exception ex) 61 | { 62 | _jobStatusLogger.Log($"Processing [{contentLink}] failed, exception: {ex}"); 63 | failedCount++; 64 | } 65 | 66 | if (currentCount % 500 == 0) 67 | { 68 | _jobStatusLogger.Status( 69 | $"Processed {currentCount} of whom successful {successCount} out of total {totalCount} references; failed: {failedCount}"); 70 | } 71 | } 72 | 73 | _jobStatusLogger.Log( 74 | $"Processed {currentCount} of whom successful {successCount} out of total {totalCount} references; failed: {failedCount}"); 75 | 76 | return _jobStatusLogger.ToString(); 77 | } 78 | 79 | public override void Stop() 80 | { 81 | _stopped = true; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/MovedContentRegistratorBackgroundService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using EPiServer.Core; 8 | using Microsoft.Extensions.Hosting; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 12 | { 13 | public class MovedContentRegistratorBackgroundService : BackgroundService 14 | { 15 | private readonly ChannelMovedContentRegistratorQueue _registratorQueue; 16 | private readonly Func _contentKeyGeneratorFactory; 17 | private readonly IAutomaticRedirectsService _automaticRedirectsService; 18 | private readonly IContentUrlHistoryLoader _contentUrlHistoryLoader; 19 | private readonly Func _contentUrlIndexerFactory; 20 | private readonly ILogger _logger; 21 | 22 | public MovedContentRegistratorBackgroundService( 23 | ChannelMovedContentRegistratorQueue registratorQueue, 24 | Func contentKeyGeneratorFactory, 25 | IAutomaticRedirectsService automaticRedirectsService, 26 | IContentUrlHistoryLoader contentUrlHistoryLoader, 27 | Func contentUrlIndexerFactory, 28 | ILogger logger) 29 | { 30 | _registratorQueue = registratorQueue; 31 | _contentKeyGeneratorFactory = contentKeyGeneratorFactory; 32 | _automaticRedirectsService = automaticRedirectsService; 33 | _contentUrlHistoryLoader = contentUrlHistoryLoader; 34 | _contentUrlIndexerFactory = contentUrlIndexerFactory; 35 | _logger = logger; 36 | } 37 | 38 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 39 | { 40 | await foreach (var contentLink in _registratorQueue.ReadAllAsync(stoppingToken)) 41 | { 42 | try 43 | { 44 | IndexContentUrls(contentLink); 45 | CreateRedirects(contentLink); 46 | } 47 | catch (Exception ex) 48 | { 49 | _logger.LogError(ex, "Failed registering redirects for content: {ContentLink}", contentLink); 50 | } 51 | } 52 | } 53 | 54 | private void CreateRedirects(ContentReference contentLink) 55 | { 56 | var contentKeyGenerator = _contentKeyGeneratorFactory(); 57 | var keyResult = contentKeyGenerator.GetContentKey(contentLink); 58 | if (!keyResult.HasValue) return; 59 | 60 | var contentKey = keyResult.Key; 61 | var histories = _contentUrlHistoryLoader.GetMoved(contentKey); 62 | _automaticRedirectsService.CreateRedirects(histories); 63 | } 64 | 65 | private void IndexContentUrls(ContentReference contentLink) 66 | { 67 | var contentUrlIndexer = _contentUrlIndexerFactory(); 68 | contentUrlIndexer.IndexContentUrls(contentLink); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/RegisterMovedContentRedirectsJob.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Linq; 6 | using EPiServer.PlugIn; 7 | using EPiServer.Scheduler; 8 | using Geta.NotFoundHandler.Optimizely.Infrastructure; 9 | 10 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 11 | { 12 | [ScheduledPlugIn(DisplayName = "[Geta NotFoundHandler] Register content move redirects", 13 | GUID = "EC96ABEE-5DA4-404F-A0C8-451C77CA4983", 14 | SortIndex = 5555)] 15 | public class RegisterMovedContentRedirectsJob : ScheduledJobBase 16 | { 17 | private readonly IContentUrlHistoryLoader _contentUrlHistoryLoader; 18 | private readonly JobStatusLogger _jobStatusLogger; 19 | private readonly IAutomaticRedirectsService _automaticRedirectsService; 20 | private bool _stopped; 21 | 22 | public RegisterMovedContentRedirectsJob( 23 | IAutomaticRedirectsService automaticRedirectsService, 24 | IContentUrlHistoryLoader contentUrlHistoryLoader) 25 | { 26 | _automaticRedirectsService = automaticRedirectsService; 27 | _contentUrlHistoryLoader = contentUrlHistoryLoader; 28 | _jobStatusLogger = new JobStatusLogger(OnStatusChanged); 29 | 30 | IsStoppable = true; 31 | } 32 | 33 | public override string Execute() 34 | { 35 | var movedContent = _contentUrlHistoryLoader.GetAllMoved().ToList(); 36 | var totalCount = movedContent.Count; 37 | var successCount = 0; 38 | var failedCount = 0; 39 | var currentCount = 0; 40 | 41 | _jobStatusLogger.LogWithStatus($"In total will process moved content: {totalCount}"); 42 | 43 | foreach (var content in movedContent) 44 | { 45 | if (_stopped) 46 | { 47 | _jobStatusLogger.Log( 48 | $"Job was stopped, successful content handled before stopped: {successCount} out of total {totalCount} content"); 49 | return _jobStatusLogger.ToString(); 50 | } 51 | 52 | currentCount++; 53 | 54 | try 55 | { 56 | _automaticRedirectsService.CreateRedirects(content.histories); 57 | successCount++; 58 | } 59 | catch (Exception ex) 60 | { 61 | _jobStatusLogger.Log($"Processing [{content.contentKey}] failed, exception: {ex}"); 62 | failedCount++; 63 | } 64 | 65 | if (currentCount % 500 == 0) 66 | { 67 | _jobStatusLogger.Status( 68 | $"Processed {currentCount} of whom successful {successCount} out of total {totalCount} content; failed: {failedCount}"); 69 | } 70 | } 71 | 72 | _jobStatusLogger.Log( 73 | $"Processed {currentCount} of whom successful {successCount} out of total {totalCount} content; failed: {failedCount}"); 74 | 75 | return _jobStatusLogger.ToString(); 76 | } 77 | 78 | public override void Stop() 79 | { 80 | _stopped = true; 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/TypedUrl.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 5 | { 6 | public record TypedUrl 7 | { 8 | public string Url { get; set; } 9 | public UrlType Type { get; set; } 10 | public string Language { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/AutomaticRedirects/UrlType.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects 5 | { 6 | public enum UrlType 7 | { 8 | Primary = 1, 9 | Secondary = 2, 10 | Seo = 3 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/Events/OptimizelySyncEvents.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using EPiServer.Events.Clients; 6 | using Geta.NotFoundHandler.Core.Providers.RegexRedirects; 7 | using Geta.NotFoundHandler.Core.Redirects; 8 | 9 | namespace Geta.NotFoundHandler.Optimizely.Core.Events 10 | { 11 | public class OptimizelySyncEvents 12 | { 13 | private readonly RedirectsEvents _redirectsEvents; 14 | private readonly IEventRegistry _eventRegistry; 15 | private readonly RedirectsInitializer _redirectsInitializer; 16 | private readonly IRegexRedirectCache _regexRedirectCache; 17 | 18 | private static readonly Guid UpdateEventId = new("{AC263F88-6C17-45A5-81E0-DCC28DF26AEF}"); 19 | private static readonly Guid RegexUpdateEventId = new("{334DF0A0-793C-4B19-A0CC-8AD63F705FDD}"); 20 | private static readonly Guid RaiserId = Guid.NewGuid(); 21 | 22 | public OptimizelySyncEvents( 23 | RedirectsEvents redirectsEvents, 24 | IEventRegistry eventRegistry, 25 | RedirectsInitializer redirectsInitializer, 26 | IRegexRedirectCache regexRedirectCache) 27 | { 28 | _redirectsEvents = redirectsEvents; 29 | _eventRegistry = eventRegistry; 30 | _redirectsInitializer = redirectsInitializer; 31 | _regexRedirectCache = regexRedirectCache; 32 | } 33 | 34 | public void Initialize() 35 | { 36 | _redirectsEvents.OnRedirectsUpdated += OnRedirectsUpdated; 37 | _redirectsEvents.OnRegexRedirectsUpdated += OnRegexRedirectsUpdated; 38 | _eventRegistry.Get(UpdateEventId).Raised += SyncRedirectsUpdateEventRaised; 39 | _eventRegistry.Get(RegexUpdateEventId).Raised += SyncRegexRedirectsUpdateEventRaised; 40 | } 41 | 42 | private void SyncRedirectsUpdateEventRaised(object sender, EPiServer.Events.EventNotificationEventArgs e) 43 | { 44 | if (e.RaiserId != RaiserId) 45 | { 46 | _redirectsInitializer.Initialize(); 47 | } 48 | } 49 | 50 | private void SyncRegexRedirectsUpdateEventRaised(object sender, EPiServer.Events.EventNotificationEventArgs e) 51 | { 52 | if (e.RaiserId != RaiserId) 53 | { 54 | _regexRedirectCache.Remove(); 55 | } 56 | } 57 | 58 | private void OnRedirectsUpdated(EventArgs e) 59 | { 60 | _eventRegistry.Get(UpdateEventId).Raise(RaiserId, UpdateEventId); 61 | } 62 | 63 | private void OnRegexRedirectsUpdated(EventArgs e) 64 | { 65 | _eventRegistry.Get(RegexUpdateEventId).Raise(RaiserId, RegexUpdateEventId); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Core/Suggestions/Jobs/SuggestionsCleanupJob.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using EPiServer.PlugIn; 5 | using EPiServer.Scheduler; 6 | using Geta.NotFoundHandler.Core.Suggestions; 7 | 8 | namespace Geta.NotFoundHandler.Optimizely.Core.Suggestions.Jobs; 9 | 10 | [ScheduledPlugIn(DisplayName = "[Geta NotFoundHandler] Suggestions cleanup job", 11 | Description = "As suggestions table grow fast we should add a possibility to clean up old suggestions", 12 | GUID = "6AE19CEC-1052-4482-97DF-981076DDD6F2", 13 | SortIndex = 5555)] 14 | public class SuggestionsCleanupJob : ScheduledJobBase 15 | { 16 | private readonly ISuggestionsCleanupService _suggestionsCleanupService; 17 | 18 | public SuggestionsCleanupJob(ISuggestionsCleanupService suggestionsCleanupService) 19 | { 20 | IsStoppable = true; 21 | _suggestionsCleanupService = suggestionsCleanupService; 22 | } 23 | 24 | public override string Execute() 25 | { 26 | _suggestionsCleanupService.Cleanup(); 27 | 28 | return string.Empty; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Geta.NotFoundHandler.Optimizely.Views/Views/Container/Index.cshtml: -------------------------------------------------------------------------------- 1 | @model dynamic 2 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Geta.NotFoundHandler.Optimizely.Views/Views/Shared/_ShellLayout.cshtml: -------------------------------------------------------------------------------- 1 | @using EPiServer.Framework.Web.Resources 2 | @using EPiServer.Shell.Navigation 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | @ClientResources.RenderResources("ShellCore") 11 | @ClientResources.RenderResources("ShellCoreLightTheme") 12 | 13 | 29 | 30 | NotFound handler 31 | 32 | 33 | @Html.AntiForgeryToken() 34 | @Html.CreatePlatformNavigationMenu() 35 |
36 | @RenderBody() 37 |
38 | 39 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Geta.NotFoundHandler.Optimizely.Views/Views/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "Shared/_ShellLayout.cshtml"; 3 | } -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Geta.NotFoundHandler.Optimizely.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | true 6 | Geta.NotFoundHandler.Optimizely 7 | NotFound handler Admin UI integration Optimizely 8 | Geta Digital 9 | Geta Digital 10 | Apache-2.0 11 | https://github.com/Geta/geta-notfoundhandler 12 | icon.png 13 | https://cdn.geta.no/opensource/icons/Geta-logo-3.png 14 | false 15 | This library contains a NotFound handler Admin user interface integration in an Optimizely project. 16 | https://github.com/Geta/geta-notfoundhandler/blob/master/CHANGELOG.md 17 | 404 NotFound 404Error Handler Geta Redirect 18 | https://github.com/Geta/geta-notfoundhandler.git 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | true 41 | true 42 | None 43 | contentFiles\any\any\modules\_protected\Geta.NotFoundHandler.Optimizely 44 | 45 | 46 | 47 | 48 | 49 | true 50 | true 51 | None 52 | build\net6.0\$(MSBuildProjectName).targets 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Infrastructure/Configuration/OptimizelyNotFoundHandlerOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Geta.NotFoundHandler.Core.Redirects; 7 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 8 | 9 | namespace Geta.NotFoundHandler.Optimizely.Infrastructure.Configuration 10 | { 11 | public class OptimizelyNotFoundHandlerOptions 12 | { 13 | public const string Section = "Geta:NotFoundHandler:Optimizely"; 14 | public const int CurrentDbVersion = 3; 15 | 16 | public bool AutomaticRedirectsEnabled { get; set; } 17 | public RedirectType AutomaticRedirectType { get; set; } = RedirectType.Temporary; 18 | 19 | private readonly List _contentKeyProviders = new(); 20 | public IEnumerable ContentKeyProviders => _contentKeyProviders; 21 | 22 | private readonly List _contentLinkProviders = new(); 23 | public IEnumerable ContentLinkProviders => _contentLinkProviders; 24 | 25 | private readonly List _contentUrlProviders = new(); 26 | public IEnumerable ContentUrlProviders => _contentUrlProviders; 27 | 28 | public OptimizelyNotFoundHandlerOptions() 29 | { 30 | AddContentKeyProviders(); 31 | AddContentLinkProviders(); 32 | AddContentUrlProviders(); 33 | } 34 | 35 | public OptimizelyNotFoundHandlerOptions AddContentKeyProviders() 36 | where T : IContentKeyProvider 37 | { 38 | var t = typeof(T); 39 | _contentKeyProviders.Add(t); 40 | return this; 41 | } 42 | 43 | public OptimizelyNotFoundHandlerOptions AddContentLinkProviders() 44 | where T : IContentLinkProvider 45 | { 46 | var t = typeof(T); 47 | _contentLinkProviders.Add(t); 48 | return this; 49 | } 50 | 51 | public OptimizelyNotFoundHandlerOptions AddContentUrlProviders() 52 | where T : IContentUrlProvider 53 | { 54 | var t = typeof(T); 55 | _contentUrlProviders.Add(t); 56 | return this; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Infrastructure/Initialization/ApplicationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using Coravel.Scheduling.Schedule; 5 | using Coravel.Scheduling.Schedule.Interfaces; 6 | using Geta.NotFoundHandler.Core.ScheduledJobs.Suggestions; 7 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 8 | using Geta.NotFoundHandler.Optimizely.Core.Events; 9 | using Microsoft.AspNetCore.Builder; 10 | using Microsoft.Extensions.DependencyInjection; 11 | 12 | namespace Geta.NotFoundHandler.Optimizely.Infrastructure.Initialization 13 | { 14 | public static class ApplicationBuilderExtensions 15 | { 16 | public static IApplicationBuilder UseOptimizelyNotFoundHandler(this IApplicationBuilder app) 17 | { 18 | var services = app.ApplicationServices; 19 | 20 | var upgrader = services.GetRequiredService(); 21 | upgrader.Start(); 22 | 23 | var syncEvents = services.GetRequiredService(); 24 | syncEvents.Initialize(); 25 | 26 | var historyEvents = services.GetRequiredService(); 27 | historyEvents.Initialize(); 28 | 29 | // For optimizely we will use built-in scheduler for this job 30 | var scheduler = services.GetService(); 31 | (scheduler as Scheduler)?.TryUnschedule(nameof(SuggestionsCleanupJob)); 32 | 33 | return app; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/Infrastructure/JobStatusLogger.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Text; 6 | 7 | namespace Geta.NotFoundHandler.Optimizely.Infrastructure 8 | { 9 | internal class JobStatusLogger 10 | { 11 | private readonly Action _onStatusChanged; 12 | 13 | private readonly StringBuilder _stringBuilder = new(); 14 | 15 | public JobStatusLogger(Action onStatusChanged) 16 | { 17 | _onStatusChanged = onStatusChanged; 18 | } 19 | 20 | public void Log(string message) 21 | { 22 | _stringBuilder.AppendLine(message); 23 | } 24 | 25 | public void LogWithStatus(string message) 26 | { 27 | message = $"{DateTime.UtcNow:yyyy-MM-dd hh:mm:ss} - {message}"; 28 | Status(message); 29 | Log(message); 30 | } 31 | 32 | public void Status(string message) 33 | { 34 | _onStatusChanged?.Invoke(message); 35 | } 36 | 37 | public string ToString(string separator = "
") 38 | { 39 | return _stringBuilder?.ToString().Replace(Environment.NewLine, separator); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/MenuProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using EPiServer.Shell; 6 | using EPiServer.Shell.Navigation; 7 | 8 | namespace Geta.NotFoundHandler.Optimizely 9 | { 10 | [MenuProvider] 11 | public class MenuProvider : IMenuProvider 12 | { 13 | public IEnumerable GetMenuItems() 14 | { 15 | var url = Paths.ToResource(GetType(), "container"); 16 | 17 | var link = new UrlMenuItem( 18 | "NotFound handler", 19 | MenuPaths.Global + "/cms/notfoundhandler", 20 | url) 21 | { 22 | SortIndex = 100, 23 | AuthorizationPolicy = NotFoundHandler.Infrastructure.Constants.PolicyName 24 | }; 25 | 26 | return new List { link }; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/module.config: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Optimizely/msbuild/CopyModule.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 13 | 14 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Web/Geta.NotFoundHandler.Web.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net9.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Web/Program.cs: -------------------------------------------------------------------------------- 1 | using Geta.NotFoundHandler.Web; 2 | 3 | Foundation.Program.CreateHostBuilder(args, 4 | webBuilder => 5 | webBuilder.UseContentRoot( 6 | Path.GetFullPath( 7 | "../../sub/geta-foundation-core/src/Foundation"))) 8 | .Build() 9 | .Run(); 10 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Web/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:30769", 8 | "sslPort": 44375 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "applicationUrl": "http://localhost:5127", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "https": { 22 | "commandName": "Project", 23 | "dotnetRunMessages": true, 24 | "launchBrowser": true, 25 | "applicationUrl": "https://localhost:7235;http://localhost:5127", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | }, 30 | "IIS Express": { 31 | "commandName": "IISExpress", 32 | "launchBrowser": true, 33 | "environmentVariables": { 34 | "ASPNETCORE_ENVIRONMENT": "Development" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler.Web/Startup.cs: -------------------------------------------------------------------------------- 1 | using EPiServer.Authorization; 2 | using EPiServer.Framework.Hosting; 3 | using EPiServer.Web.Hosting; 4 | using Geta.NotFoundHandler.Infrastructure.Configuration; 5 | using Geta.NotFoundHandler.Infrastructure.Initialization; 6 | using Geta.NotFoundHandler.Optimizely; 7 | using Geta.NotFoundHandler.Optimizely.Infrastructure.Configuration; 8 | using Geta.NotFoundHandler.Optimizely.Infrastructure.Initialization; 9 | 10 | namespace Geta.NotFoundHandler.Web; 11 | 12 | public class Startup 13 | { 14 | private readonly IConfiguration _configuration; 15 | private readonly Foundation.Startup _foundationStartup; 16 | 17 | public Startup(IWebHostEnvironment webHostingEnvironment, IConfiguration configuration) 18 | { 19 | _configuration = configuration; 20 | _foundationStartup = new Foundation.Startup(webHostingEnvironment, configuration); 21 | } 22 | 23 | public void ConfigureServices(IServiceCollection services) 24 | { 25 | services.AddNotFoundHandler(o => o.UseSqlServer(_configuration.GetConnectionString("EPiServerDB")), 26 | policy => policy.RequireRole(Roles.CmsAdmins)); 27 | services.AddOptimizelyNotFoundHandler(); 28 | _foundationStartup.ConfigureServices(services); 29 | 30 | var moduleName = typeof(ContainerController).Assembly.GetName().Name; 31 | var fullPath = Path.GetFullPath($"../{moduleName}"); 32 | 33 | services.Configure(options => 34 | { 35 | options.BasePathFileProviders.Add(new MappingPhysicalFileProvider( 36 | $"/EPiServer/{moduleName}", 37 | string.Empty, 38 | fullPath)); 39 | }); 40 | } 41 | 42 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 43 | { 44 | app.UseNotFoundHandler(); 45 | app.UseOptimizelyNotFoundHandler(); 46 | 47 | _foundationStartup.Configure(app, env); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/INotFoundHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Core 5 | { 6 | /// 7 | /// Interface for creating custom redirect handling. 8 | /// 9 | public interface INotFoundHandler 10 | { 11 | /// 12 | /// Create a redirect url from the old url. 13 | /// This could for example be done by using Regex.Replace(...) 14 | /// 15 | /// The old url which will be redirected 16 | /// The new url for the redirect. If no new url has been created, null should be returned instead. 17 | RewriteResult RewriteUrl(string url); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Providers/RegexRedirects/DefaultRegexRedirectsService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using Geta.NotFoundHandler.Core.Redirects; 6 | using Geta.NotFoundHandler.Data; 7 | 8 | namespace Geta.NotFoundHandler.Core.Providers.RegexRedirects; 9 | 10 | public class DefaultRegexRedirectsService : IRegexRedirectsService 11 | { 12 | private readonly RegexRedirectFactory _regexRedirectFactory; 13 | private readonly IRepository _repository; 14 | private readonly IRegexRedirectLoader _redirectLoader; 15 | private readonly IRegexRedirectOrderUpdater _orderUpdater; 16 | private readonly RedirectsEvents _redirectsEvents; 17 | 18 | public DefaultRegexRedirectsService( 19 | RegexRedirectFactory regexRedirectFactory, 20 | IRepository repository, 21 | IRegexRedirectLoader redirectLoader, 22 | IRegexRedirectOrderUpdater orderUpdater, 23 | RedirectsEvents redirectsEvents) 24 | { 25 | _regexRedirectFactory = regexRedirectFactory; 26 | _repository = repository; 27 | _redirectLoader = redirectLoader; 28 | _orderUpdater = orderUpdater; 29 | _redirectsEvents = redirectsEvents; 30 | } 31 | 32 | public void Create(string oldUrlRegex, string newUrlFormat, int orderNumber) 33 | { 34 | var regexRedirect = _regexRedirectFactory.CreateNew(oldUrlRegex, newUrlFormat, orderNumber); 35 | _repository.Save(regexRedirect); 36 | _orderUpdater.UpdateOrder(); 37 | _redirectsEvents.RegexRedirectsUpdated(); 38 | } 39 | 40 | public void Update(Guid id, string oldUrlRegex, string newUrlFormat, int orderNumber) 41 | { 42 | var original = _redirectLoader.Get(id); 43 | var isIncrease = original.OrderNumber < orderNumber; 44 | var regexRedirect = _regexRedirectFactory.Create(id, oldUrlRegex, newUrlFormat, orderNumber); 45 | _repository.Save(regexRedirect); 46 | _orderUpdater.UpdateOrder(isIncrease); 47 | _redirectsEvents.RegexRedirectsUpdated(); 48 | } 49 | 50 | public void Delete(Guid id) 51 | { 52 | var regexRedirect = _regexRedirectFactory.CreateForDeletion(id); 53 | _repository.Delete(regexRedirect); 54 | _orderUpdater.UpdateOrder(); 55 | _redirectsEvents.RegexRedirectsUpdated(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Providers/RegexRedirects/IRegexRedirectCache.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | 5 | namespace Geta.NotFoundHandler.Core.Providers.RegexRedirects; 6 | 7 | public interface IRegexRedirectCache 8 | { 9 | void Remove(); 10 | } 11 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Providers/RegexRedirects/IRegexRedirectsService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | 6 | namespace Geta.NotFoundHandler.Core.Providers.RegexRedirects; 7 | 8 | public interface IRegexRedirectsService 9 | { 10 | void Create(string oldUrlRegex, string newUrlFormat, int orderNumber); 11 | void Update(Guid id, string oldUrlRegex, string newUrlFormat, int orderNumber); 12 | void Delete(Guid id); 13 | } 14 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Providers/RegexRedirects/MemoryCacheRegexRedirectRepository.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Geta.NotFoundHandler.Data; 7 | using Microsoft.Extensions.Caching.Memory; 8 | 9 | namespace Geta.NotFoundHandler.Core.Providers.RegexRedirects; 10 | 11 | public class MemoryCacheRegexRedirectRepository : IRepository, IRegexRedirectLoader, IRegexRedirectOrderUpdater, IRegexRedirectCache 12 | { 13 | private readonly IRepository _repository; 14 | private readonly IRegexRedirectLoader _redirectLoader; 15 | private readonly IRegexRedirectOrderUpdater _orderUpdater; 16 | private readonly IMemoryCache _cache; 17 | 18 | private const string GetAllCacheKey = "RegexRedirects_GetAll"; 19 | 20 | public MemoryCacheRegexRedirectRepository( 21 | IRepository repository, 22 | IRegexRedirectLoader redirectLoader, 23 | IRegexRedirectOrderUpdater orderUpdater, 24 | IMemoryCache cache) 25 | { 26 | _repository = repository; 27 | _redirectLoader = redirectLoader; 28 | _orderUpdater = orderUpdater; 29 | _cache = cache; 30 | } 31 | 32 | public IEnumerable GetAll() 33 | { 34 | return _cache.GetOrCreate(GetAllCacheKey, 35 | cacheEntry => 36 | { 37 | cacheEntry.SlidingExpiration = TimeSpan.FromHours(1); 38 | return _redirectLoader.GetAll(); 39 | }); 40 | } 41 | 42 | public RegexRedirect Get(Guid id) 43 | { 44 | return _redirectLoader.Get(id); 45 | } 46 | 47 | public void Save(RegexRedirect entity) 48 | { 49 | _repository.Save(entity); 50 | Remove(); 51 | } 52 | 53 | public void Delete(RegexRedirect entity) 54 | { 55 | _repository.Delete(entity); 56 | Remove(); 57 | } 58 | 59 | public void UpdateOrder(bool isIncrease = false) 60 | { 61 | _orderUpdater.UpdateOrder(isIncrease); 62 | } 63 | 64 | public void Remove() 65 | { 66 | _cache.Remove(GetAllCacheKey); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Providers/RegexRedirects/RegexRedirect.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Text.RegularExpressions; 6 | 7 | namespace Geta.NotFoundHandler.Core.Providers.RegexRedirects; 8 | 9 | public class RegexRedirect 10 | { 11 | public RegexRedirect( 12 | Guid? id, 13 | Regex oldUrlRegex, 14 | string newUrlFormat, 15 | int orderNumber, 16 | int? timeoutCount, 17 | DateTime? createdAt, 18 | DateTime? modifiedAt) 19 | { 20 | Id = id; 21 | OldUrlRegex = oldUrlRegex; 22 | NewUrlFormat = newUrlFormat; 23 | OrderNumber = orderNumber; 24 | TimeoutCount = timeoutCount; 25 | CreatedAt = createdAt; 26 | ModifiedAt = modifiedAt; 27 | } 28 | 29 | public Guid? Id { get; } 30 | public Regex OldUrlRegex { get; } 31 | public string NewUrlFormat { get; } 32 | public int OrderNumber { get; } 33 | public int? TimeoutCount { get; } 34 | public DateTime? CreatedAt { get; } 35 | public DateTime? ModifiedAt { get; } 36 | } 37 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Providers/RegexRedirects/RegexRedirectFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Text.RegularExpressions; 6 | using Geta.NotFoundHandler.Infrastructure.Configuration; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace Geta.NotFoundHandler.Core.Providers.RegexRedirects; 10 | 11 | public class RegexRedirectFactory 12 | { 13 | private readonly NotFoundHandlerOptions _configuration; 14 | 15 | public RegexRedirectFactory(IOptions options) 16 | { 17 | _configuration = options.Value; 18 | } 19 | 20 | public virtual RegexRedirect Create( 21 | Guid id, 22 | string oldUrlRegex, 23 | string newUrlFormat, 24 | int orderNumber, 25 | int? timeoutCount = null, 26 | DateTime? createdAt = null, 27 | DateTime? modifiedAt = null) 28 | { 29 | return new RegexRedirect(id, 30 | new Regex(oldUrlRegex, RegexOptions.Compiled, _configuration.RegexTimeout), 31 | newUrlFormat, 32 | orderNumber, 33 | timeoutCount, 34 | createdAt, 35 | modifiedAt); 36 | } 37 | 38 | public virtual RegexRedirect CreateNew(string oldUrlRegex, string newUrlFormat, int orderNumber) 39 | { 40 | return new RegexRedirect(null, 41 | new Regex(oldUrlRegex, RegexOptions.Compiled, _configuration.RegexTimeout), 42 | newUrlFormat, 43 | orderNumber, 44 | 0, 45 | null, 46 | null); 47 | } 48 | 49 | public virtual RegexRedirect CreateForDeletion(Guid id) 50 | { 51 | return new RegexRedirect(id, null, string.Empty, 0, null, null, null); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Providers/RegexRedirects/RegexRedirectNotFoundHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Text.RegularExpressions; 5 | using Geta.NotFoundHandler.Data; 6 | using Geta.NotFoundHandler.Infrastructure.Configuration; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace Geta.NotFoundHandler.Core.Providers.RegexRedirects; 11 | 12 | public class RegexRedirectNotFoundHandler : INotFoundHandler 13 | { 14 | private readonly IRegexRedirectLoader _regexRedirectLoader; 15 | private readonly ILogger _logger; 16 | private readonly NotFoundHandlerOptions _options; 17 | 18 | public RegexRedirectNotFoundHandler( 19 | IRegexRedirectLoader regexRedirectLoader, 20 | ILogger logger, 21 | IOptions options) 22 | { 23 | _regexRedirectLoader = regexRedirectLoader; 24 | _logger = logger; 25 | _options = options.Value; 26 | } 27 | 28 | public RewriteResult RewriteUrl(string url) 29 | { 30 | var regexRedirects = _regexRedirectLoader.GetAll(); 31 | 32 | foreach (var redirect in regexRedirects) 33 | { 34 | try 35 | { 36 | var match = redirect.OldUrlRegex.Match(url); 37 | if (match.Success) 38 | { 39 | var newUrl = match.Result(redirect.NewUrlFormat); 40 | return new RewriteResult(newUrl, _options.DefaultRedirectType); 41 | } 42 | } 43 | catch (RegexMatchTimeoutException e) 44 | { 45 | _logger.LogWarning(e, 46 | "Regex URL match timed out. Url: {Url}; Regex: {Regex}; Timeout: {Timeout}", 47 | e.Input, 48 | e.Pattern, 49 | e.MatchTimeout); 50 | 51 | // TODO: Record timeout failure 52 | } 53 | } 54 | 55 | return RewriteResult.Empty; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/CustomRedirect.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | 6 | namespace Geta.NotFoundHandler.Core.Redirects 7 | { 8 | public class CustomRedirect 9 | { 10 | /// 11 | /// Gets or sets a value indicating whether to skip appending the 12 | /// old url fragment to the new one. Default value is false. 13 | /// 14 | /// 15 | /// If you want to redirect many addresses below a specific one to 16 | /// one new url, set this to true. If we get a wild card match on 17 | /// this url, the new url will be used in its raw format, and the 18 | /// old url will not be appended to the new one. 19 | /// 20 | /// true to skip appending old url if wild card match; otherwise, false. 21 | public bool WildCardSkipAppend { get; set; } 22 | 23 | private string _oldUrl; 24 | public string OldUrl 25 | { 26 | get => _oldUrl; 27 | set => _oldUrl = value?.ToLower(); 28 | } 29 | 30 | public string NewUrl { get; set; } 31 | 32 | public int State { get; set; } 33 | 34 | // 301 (permanent) or 302 (temporary) 35 | public RedirectType RedirectType { get; set; } 36 | 37 | public Guid? Id { get; set; } 38 | 39 | public CustomRedirect() 40 | { 41 | } 42 | 43 | public CustomRedirect(string oldUrl, string newUrl, bool skipWildCardAppend, RedirectType redirectType) 44 | : this(oldUrl, newUrl) 45 | { 46 | WildCardSkipAppend = skipWildCardAppend; 47 | RedirectType = redirectType; 48 | } 49 | 50 | public CustomRedirect(string oldUrl, RedirectState state) 51 | :this(oldUrl, string.Empty) 52 | { 53 | State = Convert.ToInt32(state); 54 | } 55 | 56 | public CustomRedirect(string oldUrl, string newUrl) 57 | { 58 | OldUrl = oldUrl; 59 | NewUrl = newUrl; 60 | } 61 | 62 | public CustomRedirect(CustomRedirect redirect) 63 | { 64 | OldUrl = redirect._oldUrl; 65 | NewUrl = redirect.NewUrl; 66 | WildCardSkipAppend = redirect.WildCardSkipAppend; 67 | RedirectType = redirect.RedirectType; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/CustomRedirectEqualityComparer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | 7 | namespace Geta.NotFoundHandler.Core.Redirects 8 | { 9 | public class CustomRedirectEqualityComparer : IEqualityComparer 10 | { 11 | public bool Equals(CustomRedirect x, CustomRedirect y) 12 | { 13 | if (ReferenceEquals(x, y)) 14 | { 15 | return true; 16 | } 17 | 18 | if (ReferenceEquals(x, null)) 19 | { 20 | return false; 21 | } 22 | 23 | if (ReferenceEquals(y, null)) 24 | { 25 | return false; 26 | } 27 | 28 | if (x.GetType() != y.GetType()) 29 | { 30 | return false; 31 | } 32 | 33 | return 34 | x.WildCardSkipAppend == y.WildCardSkipAppend 35 | && x.OldUrl == y.OldUrl 36 | && x.NewUrl == y.NewUrl 37 | && x.State == y.State 38 | && x.RedirectType == y.RedirectType; 39 | } 40 | 41 | public int GetHashCode(CustomRedirect obj) 42 | { 43 | return HashCode.Combine(obj.WildCardSkipAppend, obj.OldUrl, obj.NewUrl, obj.State, (int)obj.RedirectType); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/CustomRedirectHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | 6 | namespace Geta.NotFoundHandler.Core.Redirects 7 | { 8 | /// 9 | /// Handler for custom redirects. Loads and caches the list of custom redirects 10 | /// to ensure performance. 11 | /// 12 | public class CustomRedirectHandler : IRedirectHandler 13 | { 14 | private static readonly object _lock = new object(); 15 | private CustomRedirectCollection _customRedirects = new CustomRedirectCollection(); 16 | 17 | public CustomRedirect Find(Uri urlNotFound) 18 | { 19 | return _customRedirects.Find(urlNotFound); 20 | } 21 | 22 | public void Set(CustomRedirectCollection redirects) 23 | { 24 | lock (_lock) 25 | { 26 | _customRedirects = redirects; 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/IRedirectHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | 6 | namespace Geta.NotFoundHandler.Core.Redirects 7 | { 8 | public interface IRedirectHandler 9 | { 10 | /// 11 | /// Returns custom redirect for the not found url 12 | /// 13 | /// 14 | /// 15 | CustomRedirect Find(Uri urlNotFound); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/IRedirectsParser.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace Geta.NotFoundHandler.Core.Redirects; 4 | 5 | public interface IRedirectsParser 6 | { 7 | CustomRedirectCollection LoadFromStream(Stream xmlContent); 8 | } 9 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/IRedirectsService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | 7 | namespace Geta.NotFoundHandler.Core.Redirects 8 | { 9 | public interface IRedirectsService 10 | { 11 | IEnumerable GetAll(); 12 | IEnumerable GetSaved(); 13 | IEnumerable GetIgnored(); 14 | IEnumerable GetDeleted(); 15 | IEnumerable Search(string searchText); 16 | void AddOrUpdate(CustomRedirect redirect); 17 | void AddOrUpdate(IEnumerable redirects); 18 | void AddDeletedRedirect(string oldUrl); 19 | void DeleteByOldUrl(string oldUrl); 20 | void DeleteByOldUrl(IEnumerable oldUrls); 21 | int DeleteAll(); 22 | int DeleteAllIgnored(); 23 | void DeleteById(Guid id); 24 | CustomRedirect Get(Guid id); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/RedirectState.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Core.Redirects 5 | { 6 | public enum RedirectState 7 | { 8 | Saved = 0, 9 | Suggestion = 1, 10 | Ignored = 2, 11 | Deleted 12 | }; 13 | } -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/RedirectType.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Core.Redirects 5 | { 6 | public enum RedirectType 7 | { 8 | Permanent = 301, 9 | Temporary = 302 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/RedirectsCsvParser.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Functionality added by Jacob Spencer 06/2024 3 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 4 | 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Globalization; 8 | using System.IO; 9 | using System.Linq; 10 | using CsvHelper; 11 | using Geta.NotFoundHandler.Models; 12 | using Microsoft.Extensions.Logging; 13 | 14 | namespace Geta.NotFoundHandler.Core.Redirects; 15 | 16 | public class RedirectsCsvParser : IRedirectsParser 17 | { 18 | private readonly ILogger _logger; 19 | 20 | public RedirectsCsvParser(ILogger logger) 21 | { 22 | _logger = logger; 23 | } 24 | 25 | /// 26 | /// Reads the custom redirects information from the specified csv file 27 | /// 28 | public CustomRedirectCollection LoadFromStream(Stream csvContent) 29 | { 30 | using var reader = new StreamReader(csvContent); 31 | using var csv = new CsvReader(reader, CultureInfo.InvariantCulture); 32 | var records = csv.GetRecords().ToList(); 33 | 34 | if(!records.Any()) 35 | { 36 | _logger.LogError("NotFoundHandler: The Custom Redirects file does not exist"); 37 | return new CustomRedirectCollection(); 38 | } 39 | 40 | return Load(records); 41 | } 42 | 43 | private CustomRedirectCollection Load(IEnumerable csvImportModels) 44 | { 45 | var redirects = new CustomRedirectCollection(); 46 | 47 | foreach (var item in csvImportModels) 48 | { 49 | // Create new custom redirect nodes 50 | var redirectType = GetRedirectType(item.RedirectType); 51 | var skipWildCardAppend = GetSkipWildCardAppend(item.WildcardSkippedAppend); 52 | var redirect = new CustomRedirect(item.OldUrl, item.NewUrl, skipWildCardAppend, redirectType); 53 | redirects.Add(redirect); 54 | } 55 | 56 | return redirects; 57 | } 58 | 59 | private static bool GetSkipWildCardAppend(string skipWildCardAttr) 60 | { 61 | if (!string.IsNullOrWhiteSpace(skipWildCardAttr) && bool.TryParse(skipWildCardAttr, out var skipWildCardAppend)) 62 | { 63 | return skipWildCardAppend; 64 | } 65 | 66 | return false; 67 | } 68 | 69 | private RedirectType GetRedirectType(string redirectTypeAttr) 70 | { 71 | if (!string.IsNullOrWhiteSpace(redirectTypeAttr) && Enum.TryParse(redirectTypeAttr, out RedirectType redirectType)) 72 | { 73 | return redirectType; 74 | } 75 | 76 | return Redirects.RedirectType.Temporary; 77 | } 78 | 79 | public virtual List Export(List redirects) 80 | { 81 | return redirects.Select(item => new CsvImportModel() 82 | { 83 | NewUrl = item.NewUrl, 84 | OldUrl = item.OldUrl, 85 | RedirectType = item.RedirectType.ToString(), 86 | WildcardSkippedAppend = item.WildCardSkipAppend.ToString() 87 | }).ToList(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/RedirectsEvents.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | 6 | namespace Geta.NotFoundHandler.Core.Redirects 7 | { 8 | public class RedirectsEvents 9 | { 10 | public event EventHandler OnRedirectsUpdated; 11 | public event EventHandler OnRegexRedirectsUpdated; 12 | 13 | public void RedirectsUpdated() 14 | { 15 | OnRedirectsUpdated?.Invoke(new EventArgs()); 16 | } 17 | 18 | public void RegexRedirectsUpdated() 19 | { 20 | OnRegexRedirectsUpdated?.Invoke(new EventArgs()); 21 | } 22 | } 23 | 24 | public delegate void EventHandler(EventArgs e); 25 | } 26 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/RedirectsInitializer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | 7 | namespace Geta.NotFoundHandler.Core.Redirects 8 | { 9 | public class RedirectsInitializer 10 | { 11 | private readonly CustomRedirectHandler _redirectHandler; 12 | private readonly Func _redirectsServiceFactory; 13 | private readonly IEnumerable _providers; 14 | 15 | public RedirectsInitializer( 16 | RedirectsEvents redirectsEvents, 17 | CustomRedirectHandler redirectHandler, 18 | Func redirectsServiceFactory, 19 | IEnumerable providers) 20 | { 21 | _redirectHandler = redirectHandler; 22 | _redirectsServiceFactory = redirectsServiceFactory; 23 | _providers = providers; 24 | redirectsEvents.OnRedirectsUpdated += OnRedirectsUpdated; 25 | } 26 | 27 | private void OnRedirectsUpdated(EventArgs e) 28 | { 29 | Initialize(); 30 | } 31 | 32 | public void Initialize() 33 | { 34 | var redirects = GetCustomRedirects(); 35 | _redirectHandler.Set(redirects); 36 | } 37 | 38 | protected CustomRedirectCollection GetCustomRedirects() 39 | { 40 | var customRedirects = new CustomRedirectCollection(_providers); 41 | var redirectsService = _redirectsServiceFactory(); 42 | 43 | foreach (var redirect in redirectsService.GetAll()) 44 | { 45 | customRedirects.Add(redirect); 46 | } 47 | 48 | return customRedirects; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Redirects/RedirectsTxtParser.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace Geta.NotFoundHandler.Core.Redirects; 4 | 5 | public class RedirectsTxtParser : IRedirectsParser 6 | { 7 | public CustomRedirectCollection LoadFromStream(Stream txtContent) 8 | { 9 | var redirects = new CustomRedirectCollection(); 10 | using var streamReader = new StreamReader(txtContent); 11 | while (streamReader.Peek() >= 0) 12 | { 13 | var url = streamReader.ReadLine(); 14 | if (!string.IsNullOrEmpty(url)) 15 | { 16 | redirects.Add(new CustomRedirect { OldUrl = url, NewUrl = string.Empty, State = (int)RedirectState.Deleted }); 17 | } 18 | } 19 | 20 | return redirects; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/RewriteResult.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using Geta.NotFoundHandler.Core.Redirects; 5 | 6 | namespace Geta.NotFoundHandler.Core; 7 | 8 | public class RewriteResult 9 | { 10 | public static readonly RewriteResult Empty = new() { IsEmpty = true }; 11 | public string NewUrl { get; } 12 | public RedirectType RedirectType { get; } 13 | public bool IsEmpty { get; private set; } 14 | 15 | private RewriteResult() { } 16 | 17 | public RewriteResult(string newUrl, RedirectType redirectType) 18 | { 19 | NewUrl = newUrl; 20 | RedirectType = redirectType; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/ScheduledJobs/ApplicationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using Coravel; 5 | using Geta.NotFoundHandler.Core.ScheduledJobs.Suggestions; 6 | using Geta.NotFoundHandler.Infrastructure.Configuration; 7 | using Microsoft.AspNetCore.Builder; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.Extensions.Options; 11 | 12 | namespace Geta.NotFoundHandler.Core.ScheduledJobs; 13 | 14 | public static class ApplicationBuilderExtensions 15 | { 16 | public static IApplicationBuilder UseInternalScheduler(this IApplicationBuilder app) 17 | { 18 | var services = app.ApplicationServices; 19 | 20 | var options = services.GetRequiredService>().Value; 21 | var logger = services.GetRequiredService(); 22 | 23 | services.UseScheduler(scheduler => 24 | { 25 | scheduler 26 | .Schedule() 27 | .Cron(options.InternalSchedulerCronInterval) 28 | .PreventOverlapping(nameof(SuggestionsCleanupJob)); 29 | }) 30 | .OnError(x => 31 | { 32 | logger.LogError(x, "Something went wrong, scheduled job failed with exception"); 33 | }); 34 | 35 | return app; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/ScheduledJobs/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using Coravel; 5 | using Geta.NotFoundHandler.Core.ScheduledJobs.Suggestions; 6 | using Geta.NotFoundHandler.Infrastructure.Configuration; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace Geta.NotFoundHandler.Core.ScheduledJobs; 11 | 12 | public static class ServiceCollectionExtensions 13 | { 14 | public static IServiceCollection EnableScheduler(this IServiceCollection services) 15 | { 16 | using var serviceProvider = services.BuildServiceProvider(); 17 | var options = serviceProvider.GetRequiredService>().Value; 18 | 19 | if (options.UseInternalScheduler) 20 | { 21 | services.AddScheduler(); 22 | 23 | services.AddTransient(); 24 | } 25 | 26 | return services; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/ScheduledJobs/Suggestions/SuggestionsCleanupJob.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Threading.Tasks; 5 | using Coravel.Invocable; 6 | using Geta.NotFoundHandler.Core.Suggestions; 7 | 8 | namespace Geta.NotFoundHandler.Core.ScheduledJobs.Suggestions; 9 | 10 | public class SuggestionsCleanupJob : IInvocable 11 | { 12 | private readonly ISuggestionsCleanupService _suggestionsCleanupService; 13 | 14 | public SuggestionsCleanupJob(ISuggestionsCleanupService suggestionsCleanupService) 15 | { 16 | _suggestionsCleanupService = suggestionsCleanupService; 17 | } 18 | 19 | public async Task Invoke() 20 | { 21 | _suggestionsCleanupService.Cleanup(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Suggestions/DefaultSuggestionService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using Geta.NotFoundHandler.Core.Redirects; 5 | using Geta.NotFoundHandler.Data; 6 | using Geta.NotFoundHandler.Infrastructure.Configuration; 7 | using Microsoft.Extensions.Options; 8 | using X.PagedList; 9 | 10 | namespace Geta.NotFoundHandler.Core.Suggestions 11 | { 12 | public class DefaultSuggestionService : ISuggestionService 13 | { 14 | private readonly ISuggestionLoader _suggestionLoader; 15 | private readonly IRedirectsService _redirectsService; 16 | private readonly ISuggestionRepository _suggestionRepository; 17 | private readonly NotFoundHandlerOptions _options; 18 | 19 | public DefaultSuggestionService( 20 | ISuggestionLoader suggestionLoader, 21 | IRedirectsService redirectsService, 22 | ISuggestionRepository suggestionRepository, 23 | IOptions options) 24 | { 25 | _suggestionLoader = suggestionLoader; 26 | _redirectsService = redirectsService; 27 | _suggestionRepository = suggestionRepository; 28 | _options = options.Value; 29 | } 30 | 31 | public IPagedList GetSummaries(int page, int pageSize) 32 | { 33 | return _suggestionLoader.GetSummaries(page, pageSize); 34 | } 35 | 36 | public void AddRedirect(SuggestionRedirect suggestionRedirect) 37 | { 38 | SaveRedirect(suggestionRedirect); 39 | DeleteSuggestionsFor(suggestionRedirect.OldUrl); 40 | } 41 | 42 | public void IgnoreSuggestion(string oldUrl) 43 | { 44 | SaveIgnoredRedirect(oldUrl); 45 | DeleteSuggestionsFor(oldUrl); 46 | } 47 | 48 | public void DeleteAll() 49 | { 50 | _suggestionRepository.DeleteAll(); 51 | } 52 | 53 | public void Delete(int maxErrors, int minimumDays) 54 | { 55 | _suggestionRepository.Delete(maxErrors, minimumDays); 56 | } 57 | 58 | private void SaveIgnoredRedirect(string oldUrl) 59 | { 60 | var customRedirect = new CustomRedirect(oldUrl, RedirectState.Ignored); 61 | _redirectsService.AddOrUpdate(customRedirect); 62 | } 63 | 64 | private void SaveRedirect(SuggestionRedirect suggestionRedirect) 65 | { 66 | var customRedirect = new CustomRedirect(suggestionRedirect.OldUrl, suggestionRedirect.NewUrl, false, _options.DefaultRedirectType); 67 | _redirectsService.AddOrUpdate(customRedirect); 68 | } 69 | 70 | private void DeleteSuggestionsFor(string oldUrl) 71 | { 72 | _suggestionRepository.DeleteForRequest(oldUrl); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Suggestions/IRequestLogger.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Core.Suggestions 5 | { 6 | public interface IRequestLogger 7 | { 8 | void LogRequest(string oldUrl, string referer); 9 | bool ShouldLogRequest(string oldUrl); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Suggestions/ISuggestionService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using X.PagedList; 5 | 6 | namespace Geta.NotFoundHandler.Core.Suggestions 7 | { 8 | public interface ISuggestionService 9 | { 10 | IPagedList GetSummaries(int page, int pageSize); 11 | void AddRedirect(SuggestionRedirect suggestionRedirect); 12 | void IgnoreSuggestion(string oldUrl); 13 | void DeleteAll(); 14 | void Delete(int maxErrors, int minimumDays); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Suggestions/ISuggestionsCleanupService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Core.Suggestions; 5 | 6 | public interface ISuggestionsCleanupService 7 | { 8 | void Cleanup(); 9 | } 10 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Suggestions/LogEvent.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | 6 | namespace Geta.NotFoundHandler.Core.Suggestions 7 | { 8 | public class LogEvent 9 | { 10 | public LogEvent(string oldUrl, DateTime requested, string referer) 11 | { 12 | OldUrl = oldUrl; 13 | Requested = requested; 14 | Referer = referer; 15 | } 16 | 17 | public string OldUrl { get; set; } 18 | public DateTime Requested { get; set; } 19 | public string Referer { get; set; } 20 | } 21 | } -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Suggestions/RefererSummary.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Core.Suggestions 5 | { 6 | public class RefererSummary 7 | { 8 | public string Url { get; set; } 9 | public int Count { get; set; } 10 | public bool Unknown { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Suggestions/SuggestionRedirect.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Core.Suggestions 5 | { 6 | public class SuggestionRedirect 7 | { 8 | public string OldUrl { get; } 9 | public string NewUrl { get; } 10 | 11 | public SuggestionRedirect(string oldUrl, string newUrl) 12 | { 13 | OldUrl = oldUrl; 14 | NewUrl = newUrl; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Suggestions/SuggestionSummary.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | 6 | namespace Geta.NotFoundHandler.Core.Suggestions 7 | { 8 | public class SuggestionSummary 9 | { 10 | public string OldUrl { get; set; } 11 | public int Count { get; set; } 12 | public ICollection Referers { get; set; } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Suggestions/SuggestionsCleanupOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Core.Suggestions; 5 | 6 | public class SuggestionsCleanupOptions 7 | { 8 | public int DaysToKeep { get; set; } = 14; 9 | public int Timeout { get; set; } = 30 * 60; 10 | } 11 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Core/Suggestions/SuggestionsCleanupService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using Geta.NotFoundHandler.Infrastructure.Configuration; 6 | using Microsoft.Data.SqlClient; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace Geta.NotFoundHandler.Core.Suggestions; 11 | 12 | public class SuggestionsCleanupService : ISuggestionsCleanupService 13 | { 14 | private readonly IOptions _options; 15 | private readonly ILogger _logger; 16 | 17 | public SuggestionsCleanupService( 18 | IOptions options, 19 | ILogger logger 20 | ) 21 | { 22 | _options = options; 23 | _logger = logger; 24 | } 25 | 26 | private string CleanupCommandText(int daysToKeep) => $@" 27 | -- [NotFoundHandler.Suggestions] 28 | IF OBJECT_ID('[NotFoundHandler.Suggestions]', 'U') IS NOT NULL 29 | BEGIN 30 | DELETE 31 | FROM 32 | [NotFoundHandler.Suggestions] 33 | WHERE 34 | [Requested] < DATEADD(day, -{daysToKeep}, GETDATE()) 35 | 36 | PRINT '* Deleted ' + CAST(@@ROWCOUNT AS nvarchar) + ' outdated records from table [NotFoundHandler.Suggestions].' 37 | END 38 | "; 39 | 40 | 41 | public void Cleanup() 42 | { 43 | try 44 | { 45 | using var connection = new SqlConnection(_options.Value.ConnectionString); 46 | connection.InfoMessage += (_, e) => _logger.LogInformation("{Message}", e.Message); 47 | 48 | var command = new SqlCommand(CleanupCommandText(_options.Value.SuggestionsCleanupOptions.DaysToKeep), connection); 49 | command.CommandTimeout = _options.Value.SuggestionsCleanupOptions.Timeout; 50 | command.Connection.Open(); 51 | command.ExecuteNonQuery(); 52 | } 53 | catch (Exception ex) 54 | { 55 | _logger.LogError(ex, "There was a problem while performing cleanup on connection"); 56 | 57 | throw; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Data/IDataExecutor.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Data; 6 | using System.Data.Common; 7 | 8 | namespace Geta.NotFoundHandler.Data 9 | { 10 | public interface IDataExecutor 11 | { 12 | DataTable ExecuteQuery(string sqlCommand, params IDbDataParameter[] parameters); 13 | bool ExecuteNonQuery(string sqlCommand, params IDbDataParameter[] parameters); 14 | int ExecuteScalar(string sqlCommand); 15 | int ExecuteStoredProcedure(string sqlCommand, int defaultReturnValue = -1); 16 | DbParameter CreateParameter(string parameterName, DbType dbType); 17 | DbParameter CreateParameter(string parameterName, DbType dbType, int size); 18 | DbParameter CreateGuidParameter(string name, Guid value); 19 | DbParameter CreateStringParameter(string name, string value, int size = 2000); 20 | DbParameter CreateIntParameter(string name, int value); 21 | DbParameter CreateBoolParameter(string name, bool value); 22 | DbParameter CreateDateTimeParameter(string name, DateTime value); 23 | DbParameter CreateBinaryParameter(string name, byte[] value, int size = 8000); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Data/IRedirectLoader.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Geta.NotFoundHandler.Core.Redirects; 7 | 8 | namespace Geta.NotFoundHandler.Data 9 | { 10 | public interface IRedirectLoader 11 | { 12 | CustomRedirect GetByOldUrl(string oldUrl); 13 | IEnumerable GetAll(); 14 | IEnumerable GetByState(RedirectState state); 15 | IEnumerable Find(string searchText); 16 | CustomRedirect Get(Guid id); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Data/IRegexRedirectLoader.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Geta.NotFoundHandler.Core.Providers.RegexRedirects; 7 | 8 | namespace Geta.NotFoundHandler.Data; 9 | 10 | public interface IRegexRedirectLoader 11 | { 12 | IEnumerable GetAll(); 13 | RegexRedirect Get(Guid id); 14 | } 15 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Data/IRegexRedirectOrderUpdater.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Data; 5 | 6 | public interface IRegexRedirectOrderUpdater 7 | { 8 | public void UpdateOrder(bool isIncrease = false); 9 | } 10 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Data/IRepository.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Data 5 | { 6 | public interface IRepository 7 | where TEntity : class 8 | { 9 | void Save(TEntity entity); 10 | void Delete(TEntity entity); 11 | } 12 | } -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Data/ISuggestionLoader.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using Geta.NotFoundHandler.Core.Suggestions; 5 | using X.PagedList; 6 | 7 | namespace Geta.NotFoundHandler.Data 8 | { 9 | public interface ISuggestionLoader 10 | { 11 | IPagedList GetSummaries(int page, int pageSize); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Data/ISuggestionRepository.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | 6 | namespace Geta.NotFoundHandler.Data 7 | { 8 | public interface ISuggestionRepository 9 | { 10 | void DeleteAll(); 11 | void Delete(int maxErrors, int minimumDaysOld); 12 | void DeleteForRequest(string oldUrl); 13 | void Save(string oldUrl, string referer, DateTime requestedOn); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Geta.NotFoundHandler.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | Geta.NotFoundHandler 6 | NotFound Handler for ASP.NET Core 7 | Geta Digital 8 | Geta Digital 9 | Apache-2.0 10 | https://github.com/Geta/geta-notfoundhandler 11 | icon.png 12 | https://cdn.geta.no/opensource/icons/Geta-logo-3.png 13 | false 14 | This library contains a custom NotFound handler for your ASP.NET Core project. It will replace the default NotFound handler with one that you can change, in order for it to handle more NotFound cases than the built-in handler. It also includes a custom redirects feature for editors to add redirects. The NotFound handler logs all 404 errors as suggestions and present them in a list in the custom redirect feature, which will allow editors to easily add redirects based on the logged suggestions. 15 | https://github.com/Geta/geta-notfoundhandler/blob/master/CHANGELOG.md 16 | 404 NotFound 404Error Handler Geta Redirect 17 | https://github.com/Geta/geta-notfoundhandler.git 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Infrastructure/Configuration/FileNotFoundMode.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Infrastructure.Configuration 5 | { 6 | public enum FileNotFoundMode 7 | { 8 | /// 9 | /// 10 | /// 11 | On, 12 | /// 13 | /// 14 | /// 15 | Off, 16 | /// 17 | /// 18 | /// 19 | RemoteOnly 20 | }; 21 | } -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Infrastructure/Configuration/LoggerMode.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Infrastructure.Configuration 5 | { 6 | public enum LoggerMode 7 | { 8 | On, Off 9 | }; 10 | } -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Infrastructure/Configuration/NotFoundHandlerOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Geta.NotFoundHandler.Core; 7 | using Geta.NotFoundHandler.Core.Suggestions; 8 | using Geta.NotFoundHandler.Core.Redirects; 9 | 10 | namespace Geta.NotFoundHandler.Infrastructure.Configuration 11 | { 12 | public class NotFoundHandlerOptions 13 | { 14 | public const string Section = "Geta:NotFoundHandler"; 15 | public const int CurrentDbVersion = 2; 16 | 17 | public int BufferSize { get; set; } = 30; 18 | public int ThreshHold { get; set; } = 5; 19 | public SuggestionsCleanupOptions SuggestionsCleanupOptions { get; set; } = new(); 20 | public bool UseInternalScheduler { get; set; } 21 | public string InternalSchedulerCronInterval { get; set; } = "0 0 * * *"; 22 | public FileNotFoundMode HandlerMode { get; set; } = FileNotFoundMode.On; 23 | public TimeSpan RegexTimeout { get; set; } = TimeSpan.FromMilliseconds(100); 24 | 25 | public string[] IgnoredResourceExtensions { get; set; } = 26 | {"jpg", "gif", "png", "css", "js", "ico", "swf", "woff"}; 27 | 28 | public LoggerMode Logging { get; set; } = LoggerMode.On; 29 | public string IgnoreSuggestionsUrlRegexPattern { get; set; } 30 | public bool LogWithHostname { get; set; } = false; 31 | public string ConnectionString { get; private set; } 32 | public string BootstrapJsUrl { get; set; } = "https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"; 33 | public string BootstrapJsIntegrity { get; set; } = "sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4"; 34 | public string BootstrapCssUrl { get; set; } = "https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"; 35 | public string BootstrapCssIntegrity { get; set; } = "sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65"; 36 | public string FeatherJsUrl { get; set; } = "https://cdn.jsdelivr.net/npm/feather-icons@4.29.0/dist/feather.min.js"; 37 | public string FeatherJsIntegrity { get; set; } = "sha256-7kKJWwCLNN8n5rT1MNUpVPkeLxbwe1EZU73jiLdssrI="; 38 | 39 | private readonly List _providers = new(); 40 | public IEnumerable Providers => _providers; 41 | 42 | public RedirectType DefaultRedirectType { get; set; } = RedirectType.Temporary; 43 | 44 | public NotFoundHandlerOptions AddProvider() 45 | where T : INotFoundHandler 46 | { 47 | var t = typeof(T); 48 | _providers.Add(t); 49 | return this; 50 | } 51 | 52 | public NotFoundHandlerOptions UseSqlServer(string connectionString) 53 | { 54 | ConnectionString = connectionString; 55 | return this; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Infrastructure/Constants.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | namespace Geta.NotFoundHandler.Infrastructure 5 | { 6 | public static class Constants 7 | { 8 | public const string PolicyName = "geta:notfoundhandler"; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Infrastructure/Initialization/ApplicationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using Geta.NotFoundHandler.Core.Redirects; 5 | using Geta.NotFoundHandler.Core.ScheduledJobs; 6 | using Geta.NotFoundHandler.Infrastructure.Configuration; 7 | using Microsoft.AspNetCore.Builder; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Options; 10 | 11 | namespace Geta.NotFoundHandler.Infrastructure.Initialization 12 | { 13 | public static class ApplicationBuilderExtensions 14 | { 15 | public static IApplicationBuilder UseNotFoundHandler(this IApplicationBuilder app) 16 | { 17 | var services = app.ApplicationServices; 18 | 19 | var upgrader = services.GetRequiredService(); 20 | upgrader.Start(); 21 | 22 | var initializer = services.GetRequiredService(); 23 | initializer.Initialize(); 24 | 25 | app.UseMiddleware(); 26 | 27 | var options = services.GetRequiredService>().Value; 28 | 29 | if (options.UseInternalScheduler) 30 | { 31 | app.UseInternalScheduler(); 32 | } 33 | 34 | return app; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Infrastructure/Initialization/NotFoundHandlerMiddleware.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Threading.Tasks; 5 | using Geta.NotFoundHandler.Core; 6 | using Microsoft.AspNetCore.Http; 7 | 8 | namespace Geta.NotFoundHandler.Infrastructure.Initialization 9 | { 10 | public class NotFoundHandlerMiddleware 11 | { 12 | private readonly RequestDelegate _next; 13 | 14 | public NotFoundHandlerMiddleware(RequestDelegate next) 15 | { 16 | _next = next; 17 | } 18 | 19 | public async Task InvokeAsync(HttpContext context, RequestHandler requestHandler) 20 | { 21 | await _next(context); 22 | 23 | requestHandler.Handle(context); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Infrastructure/Processing/SpanExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | 6 | namespace Geta.NotFoundHandler.Infrastructure.Processing 7 | { 8 | internal static class SpanExtensions 9 | { 10 | public static ReadOnlySpan AsPathSpan(this string url) 11 | { 12 | var index = url.IndexOf('?'); 13 | if (index >= 0) 14 | return url.AsSpan(0, index); 15 | 16 | return url.AsSpan(); 17 | } 18 | 19 | public static ReadOnlySpan AsQuerySpan(this string url) 20 | { 21 | var index = url.IndexOf('?'); 22 | if (index >= 0) 23 | return url.AsSpan(index); 24 | 25 | return ReadOnlySpan.Empty; 26 | } 27 | 28 | public static ReadOnlySpan RemoveLeadingSlash(this ReadOnlySpan chars) 29 | { 30 | if (chars.StartsWith("/")) 31 | return chars[1..]; 32 | 33 | return chars; 34 | } 35 | 36 | public static ReadOnlySpan RemoveTrailingSlash(this ReadOnlySpan chars) 37 | { 38 | if (chars.EndsWith("/")) 39 | return chars[..^1]; 40 | 41 | return chars; 42 | } 43 | 44 | public static bool UrlPathMatch(this ReadOnlySpan path, ReadOnlySpan otherPath) 45 | { 46 | otherPath = RemoveTrailingSlash(otherPath); 47 | 48 | if (path.Length < otherPath.Length) 49 | return false; 50 | 51 | for (var i = 0; i < otherPath.Length; i++) 52 | { 53 | var currentChar = char.ToLowerInvariant(path[i]); 54 | var otherChar = char.ToLowerInvariant(otherPath[i]); 55 | 56 | if (!currentChar.Equals(otherChar)) 57 | return false; 58 | } 59 | 60 | if (path.Length == otherPath.Length) 61 | return true; 62 | 63 | return path[otherPath.Length] == '/'; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Infrastructure/Web/HttpContextExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Net; 6 | using Geta.NotFoundHandler.Core.Redirects; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.AspNetCore.Http.Extensions; 9 | 10 | namespace Geta.NotFoundHandler.Infrastructure.Web 11 | { 12 | public static class HttpContextExtensions 13 | { 14 | private const string NullIpAddress = "::1"; 15 | private static bool? _isLocalRequest; 16 | 17 | public static bool IsLocalRequest(this HttpContext httpContext) 18 | { 19 | if (_isLocalRequest.HasValue) return _isLocalRequest.Value; 20 | 21 | var connection = httpContext.Connection; 22 | 23 | _isLocalRequest = !connection.RemoteIpAddress.IsSet() || (connection.LocalIpAddress.IsSet() 24 | //If local is same as remote, then we are local 25 | ? connection.RemoteIpAddress.Equals(connection.LocalIpAddress) 26 | //else we are remote if the remote IP address is not a loopback address 27 | : IPAddress.IsLoopback(connection.RemoteIpAddress)); 28 | 29 | return _isLocalRequest.Value; 30 | } 31 | 32 | private static bool IsSet(this IPAddress address) 33 | { 34 | return address != null && address.ToString() != NullIpAddress; 35 | } 36 | 37 | public static HttpContext SetStatusCode(this HttpContext context, int statusCode) 38 | { 39 | if (!context.Response.HasStarted) 40 | { 41 | context.Response.Clear(); 42 | context.Response.StatusCode = statusCode; 43 | } 44 | 45 | return context; 46 | } 47 | 48 | public static HttpContext Redirect(this HttpContext context, string url, RedirectType redirectType) 49 | { 50 | context.Response.Clear(); 51 | 52 | var permanent = redirectType == RedirectType.Permanent; 53 | context.Response.Redirect(TryEncodeUrl(url), permanent); 54 | return context; 55 | } 56 | 57 | private static string TryEncodeUrl(string url) 58 | { 59 | Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri); 60 | 61 | return uri != null ? UriHelper.Encode(uri) : url; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Geta.NotFoundHandler/Models/CsvImportModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Functionality added by Jacob Spencer 06/2024 3 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 4 | 5 | using CsvHelper.Configuration.Attributes; 6 | 7 | namespace Geta.NotFoundHandler.Models; 8 | 9 | public class CsvImportModel 10 | { 11 | [Name("OldUrl")] 12 | public string OldUrl { get; set; } 13 | [Name("NewUrl")] 14 | public string NewUrl { get; set; } 15 | [Name("WildcardSkippedAppend")] 16 | public string WildcardSkippedAppend { get; set; } 17 | [Name("RedirectType")] 18 | public string RedirectType { get; set; } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Optimizely.Tests/AutomaticRedirects/CmsContentKeyProviderTests.cs: -------------------------------------------------------------------------------- 1 | using EPiServer.Core; 2 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 3 | using Xunit; 4 | 5 | namespace Geta.NotFoundHandler.Optimizely.Tests.AutomaticRedirects; 6 | 7 | public class CmsContentKeyProviderTests 8 | { 9 | private readonly CmsContentKeyProvider _cmsContentKeyProvider = new(); 10 | 11 | [Fact] 12 | public void GetContentKey_returns_empty_when_link_is_empty() 13 | { 14 | var result = _cmsContentKeyProvider.GetContentKey(ContentReference.EmptyReference); 15 | 16 | Assert.Equal(ContentKeyResult.Empty, result); 17 | } 18 | 19 | [Fact] 20 | public void GetContentKey_returns_empty_when_not_cms_provider() 21 | { 22 | var nonCmsLink = new ContentReference(123, 321, "NonCmsProvider"); 23 | 24 | var result = _cmsContentKeyProvider.GetContentKey(nonCmsLink); 25 | 26 | Assert.Equal(ContentKeyResult.Empty, result); 27 | } 28 | 29 | [Fact] 30 | public void GetContentKey_returns_non_empty_result_when_cms_provider() 31 | { 32 | var cmsLink = new ContentReference(122); 33 | 34 | var result = _cmsContentKeyProvider.GetContentKey(cmsLink); 35 | 36 | Assert.NotEqual(ContentKeyResult.Empty, result); 37 | } 38 | 39 | [Fact] 40 | public void GetContentKey_returns_key_with_link_id() 41 | { 42 | var linkId = 1233; 43 | var link = new ContentReference(linkId); 44 | 45 | var result = _cmsContentKeyProvider.GetContentKey(link); 46 | 47 | Assert.Equal(result.Key, linkId.ToString()); 48 | } 49 | 50 | [Fact] 51 | public void GetContentKey_returns_key_without_version_id() 52 | { 53 | var versionId = 431; 54 | var link = new ContentReference(123, versionId); 55 | 56 | var result = _cmsContentKeyProvider.GetContentKey(link); 57 | 58 | Assert.DoesNotContain(versionId.ToString(), result.Key); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Optimizely.Tests/AutomaticRedirects/CmsContentLinkProviderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using EPiServer; 4 | using EPiServer.Core; 5 | using EPiServer.Web; 6 | using FakeItEasy; 7 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 8 | using Xunit; 9 | 10 | namespace Geta.NotFoundHandler.Optimizely.Tests.AutomaticRedirects; 11 | 12 | public class CmsContentLinkProviderTests 13 | { 14 | private readonly CmsContentLinkProvider _provider; 15 | private readonly ISiteDefinitionRepository _fakeSiteDefinitionRepository; 16 | private readonly IContentLoader _fakeContentLoader; 17 | 18 | public CmsContentLinkProviderTests() 19 | { 20 | _fakeSiteDefinitionRepository = A.Fake(); 21 | _fakeContentLoader = A.Fake(); 22 | _provider = new CmsContentLinkProvider(_fakeSiteDefinitionRepository, _fakeContentLoader); 23 | } 24 | 25 | [Fact] 26 | public void GetAllLinks_returns_descendants_for_each_site() 27 | { 28 | var numberOfSites = new Random(DateTime.Now.Millisecond).Next(1, 10); 29 | var sites = A.CollectionOfDummy(numberOfSites); 30 | A.CallTo(() => _fakeSiteDefinitionRepository.List()).Returns(sites); 31 | 32 | var _ = _provider.GetAllLinks().ToList(); 33 | 34 | A.CallTo(() => _fakeContentLoader.GetDescendents(A._)).MustHaveHappened(numberOfSites, Times.Exactly); 35 | } 36 | 37 | [Fact] 38 | public void GetAllLinks_returns_descendants() 39 | { 40 | var sites = A.CollectionOfDummy(1); 41 | var numberOfDescendants = new Random(DateTime.Now.Millisecond).Next(1, 10); 42 | var descendants = A.CollectionOfDummy(numberOfDescendants); 43 | A.CallTo(() => _fakeSiteDefinitionRepository.List()).Returns(sites); 44 | A.CallTo(() => _fakeContentLoader.GetDescendents(A._)).Returns(descendants); 45 | 46 | var result = _provider.GetAllLinks().ToList(); 47 | 48 | Assert.Equal(numberOfDescendants, result.Count); 49 | Assert.Equal(descendants, result); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Optimizely.Tests/AutomaticRedirects/CommerceContentKeyProviderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using EPiServer.Core; 3 | using FakeItEasy; 4 | using Geta.NotFoundHandler.Optimizely.Commerce.AutomaticRedirects; 5 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 6 | using Mediachase.Commerce.Catalog; 7 | using Xunit; 8 | 9 | namespace Geta.NotFoundHandler.Optimizely.Tests.AutomaticRedirects; 10 | 11 | public class CommerceContentKeyProviderTests 12 | { 13 | private readonly CommerceContentKeyProvider _contentKeyProvider; 14 | private readonly ReferenceConverter _fakeReferenceConverter; 15 | 16 | private static CatalogContentType[] ValidCatalogContentTypes = 17 | { 18 | CatalogContentType.CatalogEntry, CatalogContentType.CatalogNode 19 | }; 20 | 21 | public CommerceContentKeyProviderTests() 22 | { 23 | _fakeReferenceConverter = A.Fake(); 24 | _contentKeyProvider = new CommerceContentKeyProvider(_fakeReferenceConverter); 25 | InitializeFakes(); 26 | } 27 | 28 | [Fact] 29 | public void GetContentKey_returns_empty_when_link_is_empty() 30 | { 31 | var result = _contentKeyProvider.GetContentKey(ContentReference.EmptyReference); 32 | 33 | Assert.Equal(ContentKeyResult.Empty, result); 34 | } 35 | 36 | [Fact] 37 | public void GetContentKey_returns_empty_when_not_commerce_provider() 38 | { 39 | var nonCommerceLink = new ContentReference(778, 434, "NonCatalogContent"); 40 | 41 | var result = _contentKeyProvider.GetContentKey(nonCommerceLink); 42 | 43 | Assert.Equal(ContentKeyResult.Empty, result); 44 | } 45 | 46 | [Theory] 47 | [InlineData(CatalogContentType.Root)] 48 | [InlineData(CatalogContentType.Catalog)] 49 | public void GetContentKey_returns_empty_when_unsupported_content_type(CatalogContentType contentType) 50 | { 51 | var contentLink = CreateCommerceContentLink(); 52 | A.CallTo(() => _fakeReferenceConverter.GetContentType(contentLink)).Returns(contentType); 53 | 54 | var result = _contentKeyProvider.GetContentKey(contentLink); 55 | 56 | Assert.Equal(ContentKeyResult.Empty, result); 57 | } 58 | 59 | [Fact] 60 | public void GetContentKey_returns_empty_when_no_code() 61 | { 62 | var contentLink = CreateCommerceContentLink(); 63 | A.CallTo(() => _fakeReferenceConverter.GetCode(contentLink)).Returns(string.Empty); 64 | 65 | var result = _contentKeyProvider.GetContentKey(contentLink); 66 | 67 | Assert.Equal(ContentKeyResult.Empty, result); 68 | } 69 | 70 | [Fact] 71 | public void GetContentKey_returns_key_with_code_and_provider_name() 72 | { 73 | var contentLink = CreateCommerceContentLink(); 74 | var code = Guid.NewGuid().ToString(); 75 | A.CallTo(() => _fakeReferenceConverter.GetCode(contentLink)).Returns(code); 76 | 77 | var result = _contentKeyProvider.GetContentKey(contentLink); 78 | 79 | Assert.Contains(code, result.Key); 80 | Assert.Contains(contentLink.ProviderName, result.Key); 81 | } 82 | 83 | private void InitializeFakes() 84 | { 85 | var contentType = ValidCatalogContentTypes[new Random(DateTime.Now.Millisecond).Next(0, 1)]; 86 | A.CallTo(() => _fakeReferenceConverter.GetContentType(A._)).Returns(contentType); 87 | } 88 | 89 | private static ContentReference CreateCommerceContentLink() 90 | { 91 | var random = new Random(DateTime.Now.Millisecond); 92 | return new ContentReference(random.Next(), random.Next(), "CatalogContent"); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Optimizely.Tests/AutomaticRedirects/CommerceContentLinkProviderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using EPiServer; 4 | using EPiServer.Core; 5 | using FakeItEasy; 6 | using Geta.NotFoundHandler.Optimizely.Commerce.AutomaticRedirects; 7 | using Mediachase.Commerce.Catalog; 8 | using Xunit; 9 | 10 | namespace Geta.NotFoundHandler.Optimizely.Tests.AutomaticRedirects; 11 | 12 | public class CommerceContentLinkProviderTests 13 | { 14 | private readonly ReferenceConverter _fakeReferenceConverter; 15 | private readonly IContentLoader _fakeContentLoader; 16 | private readonly CommerceContentLinkProvider _provider; 17 | 18 | public CommerceContentLinkProviderTests() 19 | { 20 | _fakeReferenceConverter = A.Fake(); 21 | _fakeContentLoader = A.Fake(); 22 | _provider = new CommerceContentLinkProvider(_fakeReferenceConverter, _fakeContentLoader); 23 | } 24 | 25 | [Fact] 26 | public void GetAllLinks_returns_root_descendents() 27 | { 28 | var random = new Random(DateTime.Now.Millisecond); 29 | var links = A.CollectionOfDummy(random.Next(10, 100)); 30 | var rootLink = new ContentReference(random.Next()); 31 | A.CallTo(() => _fakeReferenceConverter.GetRootLink()).Returns(rootLink); 32 | A.CallTo(() => _fakeContentLoader.GetDescendents(rootLink)).Returns(links); 33 | 34 | var result = _provider.GetAllLinks(); 35 | 36 | var expected = links.Count; 37 | Assert.Equal(expected, result.Count()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Optimizely.Tests/AutomaticRedirects/ContentKeyGeneratorTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using EPiServer.Core; 4 | using FakeItEasy; 5 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 6 | using Xunit; 7 | 8 | namespace Geta.NotFoundHandler.Optimizely.Tests.AutomaticRedirects; 9 | 10 | public class ContentKeyGeneratorTests 11 | { 12 | private readonly ContentKeyGenerator _generator; 13 | private List _providers; 14 | 15 | public ContentKeyGeneratorTests() 16 | { 17 | InitProviders(); 18 | _generator = new ContentKeyGenerator(_providers); 19 | } 20 | 21 | [Fact] 22 | public void GetContentKey_returns_empty_when_link_empty() 23 | { 24 | var result = _generator.GetContentKey(ContentReference.EmptyReference); 25 | 26 | Assert.Equal(ContentKeyResult.Empty, result); 27 | } 28 | 29 | [Fact] 30 | public void GetContentKey_returns_empty_when_no_results_from_providers() 31 | { 32 | var random = new Random(DateTime.Now.Millisecond); 33 | var contentLink = new ContentReference(random.Next()); 34 | A.CallTo(() => _providers[0].GetContentKey(contentLink)).Returns(ContentKeyResult.Empty); 35 | A.CallTo(() => _providers[1].GetContentKey(contentLink)).Returns(ContentKeyResult.Empty); 36 | 37 | var result = _generator.GetContentKey(contentLink); 38 | 39 | Assert.Equal(ContentKeyResult.Empty, result); 40 | } 41 | 42 | [Fact] 43 | public void GetContentKey_returns_first_key() 44 | { 45 | var random = new Random(DateTime.Now.Millisecond); 46 | var contentLink = new ContentReference(random.Next()); 47 | var firstKey = new ContentKeyResult("firstKey"); 48 | A.CallTo(() => _providers[0].GetContentKey(contentLink)).Returns(firstKey); 49 | A.CallTo(() => _providers[1].GetContentKey(contentLink)).Returns(new ContentKeyResult(Guid.NewGuid().ToString())); 50 | 51 | var result = _generator.GetContentKey(contentLink); 52 | 53 | var expected = firstKey.Key; 54 | Assert.Equal(expected, result.Key); 55 | } 56 | 57 | [Fact] 58 | public void GetContentKey_returns_first_non_empty_key() 59 | { 60 | var random = new Random(DateTime.Now.Millisecond); 61 | var contentLink = new ContentReference(random.Next()); 62 | var key = new ContentKeyResult("key"); 63 | A.CallTo(() => _providers[0].GetContentKey(contentLink)).Returns(ContentKeyResult.Empty); 64 | A.CallTo(() => _providers[1].GetContentKey(contentLink)).Returns(key); 65 | 66 | var result = _generator.GetContentKey(contentLink); 67 | 68 | var expected = key.Key; 69 | Assert.Equal(expected, result.Key); 70 | 71 | } 72 | 73 | private void InitProviders() 74 | { 75 | _providers = new List { A.Fake(), A.Fake() }; 76 | A.CallTo(() => _providers[0].GetContentKey(A._)).Returns(ContentKeyResult.Empty); 77 | A.CallTo(() => _providers[1].GetContentKey(A._)).Returns(new ContentKeyResult("key")); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Optimizely.Tests/AutomaticRedirects/ContentLinkLoaderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using EPiServer.Core; 5 | using FakeItEasy; 6 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 7 | using Xunit; 8 | 9 | namespace Geta.NotFoundHandler.Optimizely.Tests.AutomaticRedirects; 10 | 11 | public class ContentLinkLoaderTests 12 | { 13 | private readonly Random _random = new Random(DateTime.Now.Millisecond); 14 | 15 | [Fact] 16 | public void GetAllLinks_returns_links_from_all_providers() 17 | { 18 | var firstProvider = A.Fake(); 19 | var secondProvider = A.Fake(); 20 | var commonLinks = GetDummyLinks(); 21 | var firstLinks = GetDummyLinks().Union(commonLinks).ToList(); 22 | var secondLinks = GetDummyLinks().Union(commonLinks).ToList(); 23 | A.CallTo(() => firstProvider.GetAllLinks()).Returns(firstLinks); 24 | A.CallTo(() => secondProvider.GetAllLinks()).Returns(secondLinks); 25 | var loader = new ContentLinkLoader(new[] { firstProvider, secondProvider }); 26 | 27 | var links = loader.GetAllLinks(); 28 | 29 | var expected = firstLinks.Union(secondLinks).Distinct().ToList(); 30 | Assert.Equal(expected, links); 31 | } 32 | 33 | private IList GetDummyLinks() 34 | { 35 | return A.CollectionOfDummy(_random.Next(10, 50)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Optimizely.Tests/AutomaticRedirects/ContentUrlIndexerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using EPiServer.Core; 4 | using FakeItEasy; 5 | using Geta.NotFoundHandler.Data; 6 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 7 | using Xunit; 8 | 9 | namespace Geta.NotFoundHandler.Optimizely.Tests.AutomaticRedirects; 10 | 11 | public class ContentUrlIndexerTests 12 | { 13 | private readonly Random _random = new Random(DateTime.Now.Millisecond); 14 | private readonly ContentUrlIndexer _indexer; 15 | private readonly ContentKeyGenerator _fakeContentKeyGenerator; 16 | private readonly ContentUrlLoader _fakeContentUrlLoader; 17 | private readonly IRepository _fakeContentUrlHistoryRepository; 18 | private readonly IContentUrlHistoryLoader _fakeContentUrlHistoryLoader; 19 | 20 | public ContentUrlIndexerTests() 21 | { 22 | _fakeContentKeyGenerator = A.Fake(); 23 | _fakeContentUrlLoader = A.Fake(); 24 | _fakeContentUrlHistoryRepository = A.Fake>(); 25 | _fakeContentUrlHistoryLoader = A.Fake(); 26 | 27 | _indexer = new ContentUrlIndexer(_fakeContentKeyGenerator, 28 | _fakeContentUrlLoader, 29 | _fakeContentUrlHistoryRepository, 30 | _fakeContentUrlHistoryLoader); 31 | 32 | InitFakes(); 33 | } 34 | 35 | [Fact] 36 | public void IndexContentUrl_does_not_save_history_when_no_key() 37 | { 38 | var contentLink = CreateContentLink(); 39 | A.CallTo(() => _fakeContentKeyGenerator.GetContentKey(contentLink)).Returns(ContentKeyResult.Empty); 40 | 41 | _indexer.IndexContentUrls(contentLink); 42 | 43 | AssertNotSaved(); 44 | } 45 | 46 | [Fact] 47 | public void IndexContentUrl_does_not_save_history_when_already_registered() 48 | { 49 | var contentLink = CreateContentLink(); 50 | A.CallTo(() => _fakeContentUrlHistoryLoader.IsRegistered(A._)).Returns(true); 51 | 52 | _indexer.IndexContentUrls(contentLink); 53 | 54 | AssertNotSaved(); 55 | } 56 | 57 | [Fact] 58 | public void IndexContentUrl_saves_history_with_key_and_urls() 59 | { 60 | var contentLink = CreateContentLink(); 61 | var key = Guid.NewGuid().ToString(); 62 | var urls = A.CollectionOfDummy(_random.Next(2, 10)); 63 | A.CallTo(() => _fakeContentKeyGenerator.GetContentKey(contentLink)).Returns(new ContentKeyResult(key)); 64 | A.CallTo(() => _fakeContentUrlLoader.GetUrls(contentLink)).Returns(urls); 65 | 66 | _indexer.IndexContentUrls(contentLink); 67 | 68 | var expected = new ContentUrlHistory { ContentKey = key, Urls = urls }; 69 | AssertSaved(expected); 70 | } 71 | 72 | private void InitFakes() 73 | { 74 | A.CallTo(() => _fakeContentKeyGenerator.GetContentKey(A._)) 75 | .Returns(new ContentKeyResult(Guid.NewGuid().ToString())); 76 | A.CallTo(() => _fakeContentUrlHistoryLoader.IsRegistered(A._)).Returns(false); 77 | } 78 | 79 | private ContentReference CreateContentLink() 80 | { 81 | return new ContentReference(_random.Next()); 82 | } 83 | 84 | private void AssertNotSaved() 85 | { 86 | A.CallTo(() => _fakeContentUrlHistoryRepository.Save(A._)).MustNotHaveHappened(); 87 | } 88 | 89 | private void AssertSaved(ContentUrlHistory expected) 90 | { 91 | A.CallTo(() => _fakeContentUrlHistoryRepository.Save( 92 | A.That.Matches(x => x.ContentKey == expected.ContentKey 93 | && x.Urls.SequenceEqual(expected.Urls)))) 94 | .MustNotHaveHappened(); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Optimizely.Tests/AutomaticRedirects/NodeContentUrlProviderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using EPiServer.Cms.Shell; 5 | using EPiServer.Commerce.Catalog.ContentTypes; 6 | using EPiServer.Commerce.Catalog.Linking; 7 | using EPiServer.Core; 8 | using EPiServer.Web.Routing; 9 | using FakeItEasy; 10 | using Geta.NotFoundHandler.Optimizely.Commerce.AutomaticRedirects; 11 | using Geta.NotFoundHandler.Optimizely.Core.AutomaticRedirects; 12 | using Xunit; 13 | 14 | namespace Geta.NotFoundHandler.Optimizely.Tests.AutomaticRedirects; 15 | 16 | public class NodeContentUrlProviderTests 17 | { 18 | private readonly NodeContentUrlProvider _provider; 19 | private readonly IUrlResolver _fakeUrlResolver; 20 | private readonly IRelationRepository _fakeRelationRepository; 21 | 22 | public NodeContentUrlProviderTests() 23 | { 24 | _fakeUrlResolver = A.Fake(); 25 | _fakeRelationRepository = A.Fake(); 26 | _provider = new NodeContentUrlProvider(_fakeUrlResolver, _fakeRelationRepository); 27 | } 28 | 29 | [Fact] 30 | public void GetUrls_returns_empty_list_when_cant_handle_type() 31 | { 32 | var dummyContent = A.Dummy(); 33 | 34 | var results = _provider.GetUrls(dummyContent); 35 | 36 | Assert.Empty(results); 37 | } 38 | 39 | [Fact] 40 | public void GetUrls_returns_seo_url() 41 | { 42 | var node = A.Dummy(); 43 | 44 | var results = _provider.GetUrls(node); 45 | 46 | var expected = new TypedUrl { Url = $"/{node.SeoUri}", Type = UrlType.Seo, Language = node.LanguageBranch()}; 47 | Assert.Contains(results, url => url == expected); 48 | } 49 | 50 | [Fact] 51 | public void GetUrls_returns_primary_url() 52 | { 53 | var node = A.Dummy(); 54 | var parentsLinks = A.CollectionOfDummy(new Random(DateTime.Now.Millisecond).Next(1, 10)); 55 | var primaryLink = node.ParentLink; 56 | var primaryUrl = "/primary-url"; 57 | A.CallTo(() => _fakeRelationRepository.GetParents(node.ContentLink)).Returns(parentsLinks); 58 | A.CallTo(() => _fakeUrlResolver.GetUrl(primaryLink, A._, A._)).Returns(primaryUrl); 59 | 60 | var results = _provider.GetUrls(node); 61 | 62 | var expected = new TypedUrl { Url = $"{primaryUrl}/{node.RouteSegment}", Type = UrlType.Primary, Language = node.LanguageBranch() }; 63 | Assert.Contains(results, url => url == expected); 64 | } 65 | 66 | [Fact] 67 | public void GetUrls_returns_secondary_urls() 68 | { 69 | var node = A.Dummy(); 70 | var primaryLink = node.ParentLink; 71 | var parentsLinks = GetUniqueParentLinks(); 72 | A.CallTo(() => _fakeRelationRepository.GetParents(node.ContentLink)).Returns(parentsLinks); 73 | A.CallTo(() => _fakeUrlResolver.GetUrl(primaryLink, A._, A._)).Returns("/primary-url"); 74 | 75 | var results = _provider.GetUrls(node).ToList(); 76 | 77 | var expectedCount = parentsLinks.Count; 78 | var secondaryLinkCount = results.Count(x => x.Type == UrlType.Secondary); 79 | Assert.Equal(expectedCount, secondaryLinkCount); 80 | } 81 | 82 | private static List GetUniqueParentLinks() 83 | { 84 | var random = new Random(DateTime.Now.Millisecond); 85 | return A.CollectionOfDummy(new Random(DateTime.Now.Millisecond).Next(2, 10)) 86 | .Select(x => 87 | { 88 | x.Parent = new ContentReference(random.Next()); 89 | return x; 90 | }).ToList(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Optimizely.Tests/Geta.NotFoundHandler.Optimizely.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | false 6 | 7 | 8 | 9 | 10 | all 11 | runtime; build; native; contentfiles; analyzers; buildtransitive 12 | 13 | 14 | 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | all 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Tests/Base/Uris.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Geta.NotFoundHandler.Tests.Base 4 | { 5 | public static class Uris 6 | { 7 | public static Uri ToUri(this string url) 8 | { 9 | return ToUri(url, "http://example.com"); 10 | } 11 | 12 | public static Uri ToUri(this string url, string fallbackBaseUrl) 13 | { 14 | if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) return uri; 15 | return new Uri(new Uri(fallbackBaseUrl), url); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Tests/ExternalHandlerTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Threading.Tasks; 5 | using Geta.NotFoundHandler.Tests.Hosting; 6 | using Xunit; 7 | using System.Net; 8 | 9 | namespace Geta.NotFoundHandler.Tests 10 | { 11 | public class ExternalHandlerTests 12 | { 13 | [Fact] 14 | public async Task Request_does_not_build_forever() 15 | { 16 | var builder = new RedirectServerBuilder(); 17 | 18 | builder.AddRedirect("/catalog-content", "/catalog-content/catalog-content"); 19 | builder.AddRedirect("/catalog-content/nice-sweater", "/catalog-content"); 20 | builder.AddRedirect("/catalog-content/redirect-by-code", "/catalog-content/nice-sweater"); 21 | 22 | using var server = builder.Build(); 23 | using var client = server.CreateClient(); 24 | 25 | var response = await client.GetAsync("/catalog-content/redirect-by-code"); 26 | 27 | Assert.NotNull(response); 28 | Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode); 29 | 30 | var location = response.Headers.Location?.ToString(); 31 | Assert.Equal("/catalog-content/nice-sweater", location); 32 | 33 | var iterations = 0; 34 | 35 | while (response.StatusCode == HttpStatusCode.MovedPermanently) 36 | { 37 | response = await client.GetAsync(location); 38 | location = response.Headers.Location?.ToString(); 39 | 40 | Assert.True(++iterations < 100); 41 | } 42 | } 43 | 44 | [Fact] 45 | public async Task Request_doesnt_loop() 46 | { 47 | var builder = new RedirectServerBuilder(); 48 | 49 | builder.AddRedirect("https://localhost/a/b", "/"); 50 | builder.AddRedirect("/a/b?a=b", "/"); 51 | 52 | using var server = builder.Build(); 53 | using var client = server.CreateClient(); 54 | 55 | var response = await client.GetAsync("https://localhost/a/b?a=b"); 56 | 57 | Assert.NotNull(response); 58 | Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode); 59 | 60 | var location = response.Headers.Location?.ToString(); 61 | Assert.Equal("/?a=b", location); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Tests/Geta.NotFoundHandler.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | false 6 | 7 | 8 | 9 | 10 | all 11 | runtime; build; native; contentfiles; analyzers; buildtransitive 12 | 13 | 14 | 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | all 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Tests/Hosting/RedirectServerBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System.Collections.Generic; 5 | using System; 6 | using FakeItEasy; 7 | using Geta.NotFoundHandler.Core; 8 | using Geta.NotFoundHandler.Core.Redirects; 9 | using Geta.NotFoundHandler.Core.Suggestions; 10 | using Geta.NotFoundHandler.Infrastructure.Configuration; 11 | using Geta.NotFoundHandler.Infrastructure.Initialization; 12 | using Microsoft.AspNetCore.Builder; 13 | using Microsoft.AspNetCore.Routing; 14 | using Microsoft.AspNetCore.TestHost; 15 | using Microsoft.Extensions.DependencyInjection; 16 | using Microsoft.Extensions.Logging.Abstractions; 17 | using Microsoft.Extensions.Options; 18 | 19 | namespace Geta.NotFoundHandler.Tests.Hosting 20 | { 21 | public class RedirectServerBuilder : TestServerBuilder 22 | { 23 | private readonly CustomRedirectCollection _redirectCollection; 24 | private readonly IList> _endpointActions; 25 | 26 | public RedirectServerBuilder() 27 | { 28 | _redirectCollection = new CustomRedirectCollection(); 29 | _endpointActions = new List>(); 30 | } 31 | 32 | public virtual void AddRedirect(CustomRedirect customRedirect) 33 | { 34 | _redirectCollection.Add(customRedirect); 35 | } 36 | 37 | public virtual void AddRedirect(string oldUrl, string newUrl, bool skipWildCardAppend = false, RedirectType redirectType = RedirectType.Permanent) 38 | { 39 | var redirect = new CustomRedirect(oldUrl, newUrl, skipWildCardAppend, redirectType); 40 | AddRedirect(redirect); 41 | } 42 | 43 | public virtual void AddEndpoints(Action action) 44 | { 45 | _endpointActions.Add(action); 46 | } 47 | 48 | public override TestServer Build() 49 | { 50 | var redirectHandler = new CustomRedirectHandler(); 51 | 52 | redirectHandler.Set(_redirectCollection); 53 | 54 | var requestLogger = A.Fake(); 55 | var logger = NullLogger.Instance; 56 | var options = Options.Create(new NotFoundHandlerOptions()); 57 | 58 | var requestHandler = new RequestHandler(redirectHandler, requestLogger, options, logger); 59 | 60 | ConfigureServices(services => 61 | { 62 | services.AddSingleton(requestHandler); 63 | services.AddRouting(); 64 | }); 65 | 66 | Configure(app => 67 | { 68 | app.UseRouting(); 69 | app.UseMiddleware(); 70 | app.UseEndpoints(endpoints => 71 | { 72 | foreach (var action in _endpointActions) 73 | { 74 | action(endpoints); 75 | } 76 | }); 77 | }); 78 | 79 | return base.Build(); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Tests/Hosting/TestServerBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Geta Digital. All rights reserved. 2 | // Licensed under Apache-2.0. See the LICENSE file in the project root for more information 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Microsoft.AspNetCore.Hosting; 7 | using Microsoft.AspNetCore.TestHost; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.AspNetCore.Builder; 10 | 11 | namespace Geta.NotFoundHandler.Tests.Hosting 12 | { 13 | public class TestServerBuilder 14 | { 15 | private readonly IList> _serviceActions; 16 | private readonly IList> _applicationActions; 17 | 18 | public TestServerBuilder() 19 | { 20 | _applicationActions = new List>(); 21 | _serviceActions = new List>(); 22 | } 23 | 24 | public virtual void ConfigureServices(Action action) 25 | { 26 | _serviceActions.Add(action); 27 | } 28 | 29 | public virtual void Configure(Action action) 30 | { 31 | _applicationActions.Add(action); 32 | } 33 | 34 | public virtual TestServer Build() 35 | { 36 | var builder = new WebHostBuilder(); 37 | 38 | foreach (var action in _serviceActions) 39 | { 40 | builder.ConfigureServices(action); 41 | } 42 | 43 | foreach (var action in _applicationActions) 44 | { 45 | builder.Configure(action); 46 | } 47 | 48 | return new TestServer(builder); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Geta.NotFoundHandler.Tests/Providers/RegexNotFoundHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.RegularExpressions; 4 | using FakeItEasy; 5 | using Geta.NotFoundHandler.Core.Providers.RegexRedirects; 6 | using Geta.NotFoundHandler.Data; 7 | using Geta.NotFoundHandler.Infrastructure.Configuration; 8 | using Microsoft.Extensions.Logging; 9 | using Microsoft.Extensions.Options; 10 | using Xunit; 11 | 12 | namespace Geta.NotFoundHandler.Tests.Providers; 13 | 14 | public class RegexNotFoundHandlerTests 15 | { 16 | private readonly IRegexRedirectLoader _fakeRegexRedirectLoader; 17 | private readonly RegexRedirectNotFoundHandler _sut; 18 | 19 | public RegexNotFoundHandlerTests() 20 | { 21 | _fakeRegexRedirectLoader = A.Fake(); 22 | var fakeLogger = A.Fake>(); 23 | var fakeOptions = A.Fake>(); 24 | _sut = new RegexRedirectNotFoundHandler(_fakeRegexRedirectLoader, fakeLogger, fakeOptions); 25 | } 26 | 27 | [Fact] 28 | public void RewriteUrl_regex_matches_url_with_named_groups() 29 | { 30 | var regexRedirects = new List 31 | { 32 | RegexRedirect(@"(?I-[^=?]+)[?]{0,1}(?.*)", "/catalog-content/redirect-by-code?code=${code}&${query}") 33 | }; 34 | A.CallTo(() => _fakeRegexRedirectLoader.GetAll()).Returns(regexRedirects); 35 | 36 | var result = _sut.RewriteUrl("https://test.example.com/I-123?a=b"); 37 | 38 | Assert.Equal("/catalog-content/redirect-by-code?code=I-123&a=b", result.NewUrl); 39 | } 40 | 41 | [Fact] 42 | public void RewriteUrl_regex_matches_url_with_group_index() 43 | { 44 | var regexRedirects = new List 45 | { 46 | RegexRedirect(@"(I-[^=?]+)[?]{0,1}(.*)", "/catalog-content/redirect-by-code?code=$1&$2") 47 | }; 48 | A.CallTo(() => _fakeRegexRedirectLoader.GetAll()).Returns(regexRedirects); 49 | 50 | var result = _sut.RewriteUrl("https://test.example.com/I-123?a=b"); 51 | 52 | Assert.Equal("/catalog-content/redirect-by-code?code=I-123&a=b", result.NewUrl); 53 | } 54 | 55 | private static RegexRedirect RegexRedirect(string oldUrlRegex, string newUrlFormat, int orderNumber = 1, int timoutCount = 0) 56 | { 57 | return new RegexRedirect(Guid.NewGuid(), 58 | new Regex(oldUrlRegex, RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)), 59 | newUrlFormat, 60 | orderNumber, 61 | timoutCount, 62 | DateTime.UtcNow, 63 | DateTime.UtcNow); 64 | } 65 | } 66 | --------------------------------------------------------------------------------