├── .all-contributorsrc ├── .editorconfig ├── .github └── workflows │ ├── dotnet-build.yml │ ├── playwright.yml │ └── translate.yml ├── .gitignore ├── .vscode ├── extensions.json └── launch.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Directory.Build.props ├── Directory.Packages.props ├── Dockerfile ├── LICENSE ├── README.md ├── SharpSite.sln ├── UnitTests.slnf ├── artifacts └── FirstPlugin │ └── Sample.FirstThemePlugin@0.1.0.sspkg ├── build-and-test.ps1 ├── doc ├── PluginArchitecture.md ├── README.md └── plugin-schema.json ├── e2e ├── README.md └── SharpSite.E2E │ ├── AuthenticatedPageTests.cs │ ├── FirstLoginTests.cs │ ├── FirstWebsiteTests.cs │ ├── ProfileTests.cs │ ├── SharpSite.E2E.csproj │ └── SharpSitePageTest.cs ├── global.json ├── img ├── Blender │ ├── 0001-0150.mp4 │ ├── Font.md │ ├── black_0001-0150.webm │ ├── logo.blend │ ├── logo.blend1 │ ├── sharpsite_org_header.blend │ ├── sharpsite_org_header.blend1 │ └── transparent_0001-0150.webm ├── empty.png ├── empty.svg ├── logo.png ├── logo_preview.odg ├── logo_preview.png └── logo_transparent.png ├── nuget.config ├── plugins ├── README.md ├── Sample.FirstThemePlugin │ ├── MyTheme.cs │ ├── Sample.FirstThemePlugin.csproj │ ├── _Imports.razor │ └── wwwroot │ │ └── theme.css └── SharpSite.Plugins.FileStorage.FileSystem │ ├── FileSystemConfigurationSection.cs │ ├── FileSystemStorage.cs │ ├── MimeTypesMap.cs │ └── SharpSite.Plugins.FileStorage.FileSystem.csproj ├── scripts └── README.md ├── src ├── README.md ├── SharpSite.Abstractions.Base │ ├── IRegisterServices.cs │ ├── IRunAtStartup.cs │ ├── ISharpSiteConfiguration.cs │ ├── ISharpSiteConfigurationSection.cs │ ├── RegisterPluginAttribute.cs │ └── SharpSite.Abstractions.Base.csproj ├── SharpSite.Abstractions.FileStorage │ ├── FileData.cs │ ├── IHandleFileStorage.cs │ ├── InvalidFolderException.cs │ └── SharpSite.Abstractions.FileStorage.csproj ├── SharpSite.Abstractions.Theme │ ├── IHasStylesheets.cs │ └── SharpSite.Abstractions.Theme.csproj ├── SharpSite.Abstractions │ ├── Constants.cs │ ├── IPageRepository.cs │ ├── IPostRepository.cs │ ├── IUserRepository.cs │ ├── Page.cs │ ├── Post.cs │ ├── SharpSite.Abstractions.csproj │ └── SharpSiteUser.cs ├── SharpSite.AppHost │ ├── PostgresExtensions.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── RunE2ETestsCommand.cs │ ├── SharpSite.AppHost.csproj │ ├── appsettings.Development.json │ └── appsettings.json ├── SharpSite.Data.Postgres.Migration │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── SharpSite.Data.Postgres.Migration.csproj │ ├── Worker.cs │ ├── appsettings.Development.json │ └── appsettings.json ├── SharpSite.Data.Postgres │ ├── Migrations │ │ ├── 20241015163007_Initial.Designer.cs │ │ ├── 20241015163007_Initial.cs │ │ ├── 20241017152941_Maxlength for slug.Designer.cs │ │ ├── 20241017152941_Maxlength for slug.cs │ │ ├── 20241017153401_Maxlength for slug part 2.Designer.cs │ │ ├── 20241017153401_Maxlength for slug part 2.cs │ │ ├── 20241029161246_Add description to post.Designer.cs │ │ ├── 20241029161246_Add description to post.cs │ │ ├── 20241031151541_Add Page to database.Designer.cs │ │ ├── 20241031151541_Add Page to database.cs │ │ ├── 20241127172544_Add Page LastUpdate.Designer.cs │ │ ├── 20241127172544_Add Page LastUpdate.cs │ │ ├── 20241210172309_PostLastUpdate.Designer.cs │ │ ├── 20241210172309_PostLastUpdate.cs │ │ ├── 20250101182712_AddLanguageCodeToPostsAndPages.Designer.cs │ │ ├── 20250101182712_AddLanguageCodeToPostsAndPages.cs │ │ └── PgContextModelSnapshot.cs │ ├── PgContext.cs │ ├── PgPage.cs │ ├── PgPageRepository.cs │ ├── PgPost.cs │ ├── PgPostRepository.cs │ ├── RegisterPostgresServices.cs │ └── SharpSite.Data.Postgres.csproj ├── SharpSite.Plugins │ ├── Plugin.cs │ ├── PluginAssembly.cs │ ├── PluginAssemblyLoadContext.cs │ ├── PluginAssemblyManager.cs │ ├── PluginException.cs │ ├── PluginManifest.cs │ ├── PluginManifestExtensions.cs │ └── SharpSite.Plugins.csproj ├── SharpSite.Security.Postgres │ ├── Account │ │ ├── Pages │ │ │ ├── AccessDenied.razor │ │ │ ├── ConfirmEmail.razor │ │ │ ├── ConfirmEmailChange.razor │ │ │ ├── ExternalLogin.razor │ │ │ ├── ForgotPassword.razor │ │ │ ├── ForgotPasswordConfirmation.razor │ │ │ ├── InvalidPasswordReset.razor │ │ │ ├── InvalidUser.razor │ │ │ ├── Lockout.razor │ │ │ ├── Login.razor │ │ │ ├── LoginWith2fa.razor │ │ │ ├── LoginWithRecoveryCode.razor │ │ │ ├── Manage │ │ │ │ ├── ChangePassword.razor │ │ │ │ ├── DeletePersonalData.razor │ │ │ │ ├── Disable2fa.razor │ │ │ │ ├── Email.razor │ │ │ │ ├── EnableAuthenticator.razor │ │ │ │ ├── ExternalLogins.razor │ │ │ │ ├── GenerateRecoveryCodes.razor │ │ │ │ ├── Index.razor │ │ │ │ ├── PersonalData.razor │ │ │ │ ├── ResetAuthenticator.razor │ │ │ │ ├── SetPassword.razor │ │ │ │ ├── TwoFactorAuthentication.razor │ │ │ │ └── _Imports.razor │ │ │ ├── Register.razor │ │ │ ├── RegisterConfirmation.razor │ │ │ ├── ResendEmailConfirmation.razor │ │ │ ├── ResetPassword.razor │ │ │ ├── ResetPasswordConfirmation.razor │ │ │ └── _Imports.razor │ │ ├── Shared │ │ │ ├── ExternalLoginPicker.razor │ │ │ ├── ManageLayout.razor │ │ │ ├── ManageNavMenu.razor │ │ │ ├── ShowRecoveryCodes.razor │ │ │ └── StatusMessage.razor │ │ └── _Imports.razor │ ├── IdentityComponentsEndpointRouteBuilderExtensions.cs │ ├── IdentityNoOpEmailSender.cs │ ├── IdentityRedirectManager.cs │ ├── IdentityRevalidatingAuthenticationStateProvider.cs │ ├── IdentityUserAccessor.cs │ ├── Migrations │ │ ├── 20241205153739_InitialSecurity.Designer.cs │ │ ├── 20241205153739_InitialSecurity.cs │ │ ├── 20241206163417_User DisplayName.Designer.cs │ │ ├── 20241206163417_User DisplayName.cs │ │ └── PgSecurityContextModelSnapshot.cs │ ├── PgSharpSiteUser.cs │ ├── Properties │ │ └── launchSettings.json │ ├── RegisterPostgresSecurityServices.cs │ ├── SharpSite.Security.Postgres.csproj │ └── UserRepository.cs ├── SharpSite.ServiceDefaults │ ├── Extensions.cs │ └── SharpSite.ServiceDefaults.csproj └── SharpSite.Web │ ├── ApplicatonState.cs │ ├── Components │ ├── Admin │ │ ├── AddPlugin.razor │ │ ├── AdminLayout.razor │ │ ├── AdminSiteSettings.razor │ │ ├── ConfigurePageNotFound.razor │ │ ├── EditPage.razor │ │ ├── EditPost.razor │ │ ├── EditProperty.razor │ │ ├── EnumField.razor │ │ ├── Index.razor │ │ ├── LanguageSelect.razor │ │ ├── ManageNavMenu.razor │ │ ├── PageList.razor │ │ ├── PluginCard.razor │ │ ├── PluginCard.razor.css │ │ ├── PluginConfigUI.razor │ │ ├── PluginList.razor │ │ ├── PostList.razor │ │ ├── SiteAppearance.razor │ │ ├── ThemeSelect.razor │ │ ├── UserList.razor │ │ └── _Imports.razor │ ├── App.razor │ ├── LanguagePicker.razor │ ├── Layout │ │ ├── MainLayout.razor │ │ ├── MainLayout.razor.css │ │ ├── NavMenu.razor │ │ ├── NavMenu.razor.css │ │ └── Theme.razor │ ├── PageNotFound.razor │ ├── Pages │ │ ├── About.razor │ │ ├── DisplayPage.razor │ │ ├── DisplayPost.razor │ │ ├── Error.razor │ │ └── Home.razor │ ├── PostView.razor │ ├── RedirectToLogin.razor │ ├── Routes.razor │ ├── SeoHeaderTags.razor │ ├── TextEditor.razor │ └── _Imports.razor │ ├── FileApi.cs │ ├── IdentityNoOpEmailSender.cs │ ├── Locales │ ├── Configuration.cs │ ├── SharedResource.Designer.cs │ ├── SharedResource.bg.resx │ ├── SharedResource.ca.resx │ ├── SharedResource.de.resx │ ├── SharedResource.en.resx │ ├── SharedResource.es.resx │ ├── SharedResource.fi.resx │ ├── SharedResource.fr.resx │ ├── SharedResource.it.resx │ ├── SharedResource.nl.resx │ ├── SharedResource.pt.resx │ ├── SharedResource.resx │ ├── SharedResource.sv.resx │ └── SharedResource.sw.resx │ ├── PluginManager.cs │ ├── PluginManagerExtensions.cs │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── RobotsTxt.cs │ ├── RouteValues.cs │ ├── Rss.cs │ ├── SharedResource.cs │ ├── SharpSite.Web.csproj │ ├── SharpsiteConfigurationExtensions.cs │ ├── Sitemap.cs │ ├── appsettings.Development.json │ ├── appsettings.json │ └── wwwroot │ ├── app.css │ ├── app.js │ ├── bootstrap │ ├── bootstrap.min.css │ └── bootstrap.min.css.map │ ├── favicon.png │ ├── logo.webp │ └── plugin-icon.svg └── tests ├── SharpSite.Tests.Plugins ├── SharpSite.Tests.Plugins.csproj └── ValidateManifest.cs └── SharpSite.Tests.Web ├── ApplicationState ├── BaseFixture.cs ├── Load │ ├── WhenFileDoesNotExist.cs │ └── WhenFileExists.cs └── SetConfigurationSection │ ├── WhenAddingNewSection.cs │ └── WhenSettingNullSection.cs ├── PluginManager └── HandleUploadedPlugin.cs ├── RSS └── GenerateRSS.cs ├── RobotsTxt └── GenerateRobotsTxt.cs ├── SharpSite.Tests.Web.csproj └── Sitemap └── GenerateSitemap.cs /.github/workflows/translate.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | name: Create translation pull request 3 | 4 | # Controls when the action will run. Triggers the workflow on push or pull request 5 | # events but only for the main branch 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | paths: 10 | - '**.resx' # XML-based (resource) translation file format, .NET 11 | workflow_dispatch: 12 | 13 | # GitHub automatically creates a GITHUB_TOKEN secret to use in your workflow. 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | 17 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 18 | jobs: 19 | # This workflow contains a single job called "build" 20 | build: 21 | # The type of runner that the job will run on 22 | runs-on: ubuntu-latest 23 | 24 | # Steps represent a sequence of tasks that will be executed as part of the job 25 | steps: 26 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 27 | - uses: actions/checkout@v2 28 | 29 | # Use the machine-translator to automatically translate resource files 30 | - name: Machine Translator 31 | id: translator 32 | uses: IEvangelist/resource-translator@v2.2.1 33 | with: 34 | # The source locale (for example, 'en') used to create the glob pattern 35 | # for which resource (**/*.en.resx) files to use as input 36 | sourceLocale: 'en' 37 | # The Azure Cognitive Services translator resource subscription key 38 | subscriptionKey: ${{ secrets.AZURE_TRANSLATOR_SUBSCRIPTION_KEY }} 39 | # The Azure Cognitive Services translator resource endpoint. 40 | endpoint: ${{ secrets.AZURE_TRANSLATOR_ENDPOINT }} 41 | # (Optional) The Azure Cognitive Services translator resource region. 42 | # This is optional when using a global translator resource. 43 | region: 'eastus' 44 | # (Optional) Locales to translate to, otherwise all possible locales 45 | # are targeted. Requires double quotes. 46 | toLocales: '["bg","es","fi","fr","de","it","nl","pt","sv","sw"]' 47 | 48 | - name: create-pull-request 49 | uses: peter-evans/create-pull-request@v3.4.1 50 | if: ${{ steps.translator.outputs.has-new-translations }} == 'true' 51 | with: 52 | title: '${{ steps.translator.outputs.summary-title }}' 53 | commit-message: '${{ steps.translator.outputs.summary-details }}' 54 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "editorconfig.editorconfig", 8 | "github.vscode-github-actions", 9 | "ms-dotnettools.csdevkit", 10 | "ms-vscode.powershell", 11 | ], 12 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 13 | "unwantedRecommendations": [ 14 | 15 | ], 16 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "C#: SharpSite.AppHost Debug", 9 | "type": "dotnet", 10 | "request": "launch", 11 | "projectPath": "${workspaceFolder}/SharpSite.AppHost/SharpSite.AppHost.csproj" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Introduction 4 | 5 | We, as members of the SharpSite community, pledge to make participation in our project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others’ private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project leader, Jeff Fritz, at his email address . All complaints will be reviewed and investigated promptly and fairly. 38 | 39 | Project maintainers are obligated to respect the privacy and security of the reporter of any incident. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [.NET Foundation Code of Conduct](https://dotnetfoundation.org/about/code-of-conduct). 44 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | false 5 | bin\Debug\ 6 | 7 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | true 5 | true 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the mcr.microsoft.com/dotnet/aspnet:9.0 base image 2 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base 3 | WORKDIR /app 4 | EXPOSE 80 5 | 6 | # Use the mcr.microsoft.com/dotnet/sdk:9.0 base image for build stage 7 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build 8 | WORKDIR /src 9 | 10 | # Copy in the Directory.Build and Directory.Packages files 11 | COPY ["Directory.Build.props", "Directory.Build.props"] 12 | COPY ["Directory.Packages.props", "Directory.Packages.props"] 13 | 14 | COPY ["src/SharpSite.Web/SharpSite.Web.csproj", "SharpSite.Web/"] 15 | COPY ["src/SharpSite.Data.Postgres/SharpSite.Data.Postgres.csproj", "SharpSite.Data.Postgres/"] 16 | COPY ["src/SharpSite.Security.Postgres/SharpSite.Security.Postgres.csproj", "SharpSite.Security.Postgres/"] 17 | COPY ["src/SharpSite.ServiceDefaults/SharpSite.ServiceDefaults.csproj", "SharpSite.ServiceDefaults/"] 18 | COPY ["src/SharpSite.Plugins/SharpSite.Plugins.csproj", "SharpSite.Plugins/"] 19 | COPY ["src/SharpSite.Abstractions/SharpSite.Abstractions.csproj", "SharpSite.Abstractions/"] 20 | 21 | # Copy the other projects that SharpSite.Abstractions depends on 22 | COPY ["src/SharpSite.Abstractions.Base/SharpSite.Abstractions.Base.csproj", "SharpSite.Abstractions.Base/"] 23 | COPY ["src/SharpSite.Abstractions.FileStorage/SharpSite.Abstractions.FileStorage.csproj", "SharpSite.Abstractions.FileStorage/"] 24 | COPY ["src/SharpSite.Abstractions.Theme/SharpSite.Abstractions.Theme.csproj", "SharpSite.Abstractions.Theme/"] 25 | 26 | RUN dotnet restore "SharpSite.Web/SharpSite.Web.csproj" 27 | COPY src/. . 28 | WORKDIR "/src/SharpSite.Web" 29 | RUN dotnet build "SharpSite.Web.csproj" -c Release -o /app/build 30 | 31 | # Publish the application 32 | FROM build AS publish 33 | RUN dotnet publish "SharpSite.Web.csproj" -c Release -o /app/publish 34 | 35 | # Copy the published output from the build stage 36 | FROM base AS final 37 | WORKDIR /app 38 | COPY --from=publish /app/publish . 39 | # Set the entry point to dotnet SharpSite.Web.dll 40 | ENTRYPOINT ["dotnet", "SharpSite.Web.dll"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Fritz and Friends 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /UnitTests.slnf: -------------------------------------------------------------------------------- 1 | { 2 | "solution": { 3 | "path": "SharpSite.sln", 4 | "projects": [ 5 | "tests\\SharpSite.Tests.Web\\SharpSite.Tests.Web.csproj" 6 | ] 7 | } 8 | } -------------------------------------------------------------------------------- /artifacts/FirstPlugin/Sample.FirstThemePlugin@0.1.0.sspkg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/artifacts/FirstPlugin/Sample.FirstThemePlugin@0.1.0.sspkg -------------------------------------------------------------------------------- /build-and-test.ps1: -------------------------------------------------------------------------------- 1 | $websiteUrl = "http://localhost:5020" # Adjust the URL as needed 2 | 3 | $env:ASPIRE_ALLOW_UNSECURED_TRANSPORT="true" 4 | 5 | # Delete the stop-aspire file if it exists 6 | $stopAspireFilePath = Join-Path -Path "$PSScriptRoot/src/SharpSite.AppHost" -ChildPath "stop-aspire" 7 | if (Test-Path -Path $stopAspireFilePath) { 8 | Remove-Item -Path $stopAspireFilePath -Force 9 | } 10 | 11 | # Run the .NET Aspire application in the background 12 | $dotnetRunProcess = Start-Process -FilePath "dotnet" -ArgumentList "run -lp http --project src/SharpSite.AppHost/SharpSite.AppHost.csproj --testonly=true" -NoNewWindow -PassThru -RedirectStandardOutput "output.log" 13 | 14 | # Function to check if the website is running 15 | function Test-Website { 16 | param ( 17 | [string]$url 18 | ) 19 | try { 20 | $response = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 5 21 | return $true 22 | } catch { 23 | return $false 24 | } 25 | } 26 | 27 | # Wait for the website to be running 28 | Write-Host "Waiting for the website to start..." -ForegroundColor Yellow 29 | $maxRetries = 90 30 | $retryCount = 0 31 | while (-not (Test-Website -url $websiteUrl) -and $retryCount -lt $maxRetries) { 32 | Start-Sleep -Seconds 2 33 | $retryCount++ 34 | } 35 | 36 | if ($retryCount -eq $maxRetries) { 37 | Write-Host "Website did not start within the expected time." -ForegroundColor Red 38 | 39 | # Stop the dotnet run process 40 | Stop-Process -Id $dotnetRunProcess.Id -Force 41 | exit 1 42 | } 43 | 44 | Write-Host "Website is running!" -ForegroundColor Green 45 | 46 | # Change directory to the Playwright tests folder 47 | # Set-Location -Path "$PSScriptRoot/e2e/SharpSite.E2E" 48 | 49 | # Run Playwright tests using dotnet test 50 | dotnet test ./e2e/SharpSite.E2E/SharpSite.E2E.csproj --logger trx --results-directory "playwright-test-results" 51 | 52 | if ($LASTEXITCODE -ne 0) { 53 | Write-Host "Playwright tests failed!" -ForegroundColor Red 54 | 55 | 56 | # Create a file called stop-aspire 57 | $stopAspireFilePath = Join-Path -Path "$PSScriptRoot/src/SharpSite.AppHost" -ChildPath "stop-aspire" 58 | New-Item -Path $stopAspireFilePath -ItemType File -Force | Out-Null 59 | Set-Location -Path "$PSScriptRoot" 60 | exit $LASTEXITCODE 61 | } 62 | 63 | Write-Host "Build and tests completed successfully!" -ForegroundColor Green 64 | 65 | # Stop the dotnet run process 66 | $stopAspireFilePath = Join-Path -Path "$PSScriptRoot/src/SharpSite.AppHost" -ChildPath "stop-aspire" 67 | New-Item -Path $stopAspireFilePath -ItemType File -Force | Out-Null 68 | 69 | Set-Location -Path "$PSScriptRoot" 70 | -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | # SharpSite Docs 2 | 3 | This is a repository for documentation both for SharpSite administators but also for folks that are building plugins and features for the SharpSite framework. 4 | 5 | ## Framework Design docs 6 | 7 | These are documents discussing and planning features for SharpSite. 8 | 9 | - [Plugin Architecture](PluginArchitecture.md) -------------------------------------------------------------------------------- /doc/plugin-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string" 7 | }, 8 | "DisplayName": { 9 | "type": "string" 10 | }, 11 | "Description": { 12 | "type": "string" 13 | }, 14 | "Version": { 15 | "type": "string" 16 | }, 17 | "Published": { 18 | "type": "string" 19 | }, 20 | "SupportedVersions": { 21 | "type": "string" 22 | }, 23 | "Author": { 24 | "type": "string" 25 | }, 26 | "Contact": { 27 | "type": "string" 28 | }, 29 | "ContactEmail": { 30 | "type": "string" 31 | }, 32 | "AuthorWebsite": { 33 | "type": "string" 34 | }, 35 | "Source": { 36 | "type": "string" 37 | }, 38 | "KnownLicense": { 39 | "type": "string" 40 | }, 41 | "Tags": { 42 | "type": "array", 43 | "items": [ 44 | { 45 | "type": "string" 46 | }, 47 | { 48 | "type": "string" 49 | } 50 | ] 51 | }, 52 | "Features": { 53 | "type": "array", 54 | "items": [ 55 | { 56 | "type": "string" 57 | } 58 | ] 59 | } 60 | }, 61 | "required": [ 62 | "id", 63 | "DisplayName", 64 | "Description", 65 | "Version", 66 | "Published", 67 | "SupportedVersions", 68 | "Author", 69 | "Contact", 70 | "ContactEmail", 71 | "AuthorWebsite", 72 | "Source", 73 | "KnownLicense", 74 | "Tags", 75 | "Features" 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | # e2e Folder 2 | 3 | This is where the end to end tests for the SharpSite framework are stored these tests will be written with Playwright -------------------------------------------------------------------------------- /e2e/SharpSite.E2E/AuthenticatedPageTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Playwright; 2 | 3 | namespace SharpSite.E2E; 4 | 5 | public abstract class AuthenticatedPageTests : SharpSitePageTest 6 | { 7 | 8 | private const string URL_LOGIN = "/Account/Login"; 9 | private const string LOGIN_USERID = "admin@Localhost"; 10 | private const string LOGIN_PASSWORD = "Admin123!"; 11 | 12 | protected async Task LoginAsDefaultAdmin() 13 | { 14 | await Page.GotoAsync(URL_LOGIN); 15 | await Page.GetByRole(AriaRole.Link, new() { Name = "Login" }).ClickAsync(); 16 | await Page.GetByRole(AriaRole.Textbox, new() { Name = "Input.Email" }) 17 | .FillAsync(LOGIN_USERID); 18 | await Page.GetByRole(AriaRole.Textbox, new() { Name = "Input.Password" }) 19 | .FillAsync(LOGIN_PASSWORD); 20 | await Page.GetByRole(AriaRole.Button, new() { Name = "loginbutton" }).ClickAsync(); 21 | } 22 | 23 | protected async Task Logout() 24 | { 25 | await Page.GetByRole(AriaRole.Button, new() { Name = "Logout" }).ClickAsync(); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /e2e/SharpSite.E2E/FirstLoginTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Playwright; 2 | 3 | namespace SharpSite.E2E; 4 | 5 | public class FirstLoginTests : AuthenticatedPageTests 6 | { 7 | 8 | 9 | [Fact] 10 | public async Task HasLoginLink() 11 | { 12 | await Page.GotoAsync("/"); 13 | // Click the get started link. 14 | await Page.GetByRole(AriaRole.Link, new() { Name = "Login" }).ClickAsync(); 15 | // take a screenshot 16 | await Page.ScreenshotAsync(new PageScreenshotOptions() { Path = "login.png" }); 17 | // Expects page to have a heading with the name of Installation. 18 | await Expect(Page.GetByRole(AriaRole.Link, new() { Name = "Login" })).ToBeVisibleAsync(); 19 | } 20 | 21 | // add a test that clicks the login link and then logs in 22 | [Fact] 23 | public async Task CanLogin() 24 | { 25 | await LoginAsDefaultAdmin(); 26 | await Page.ScreenshotAsync(new PageScreenshotOptions() { Path = "loggedin.png" }); 27 | 28 | // check for the manage profile link with the text "Site Admin" 29 | await Expect(Page.GetByRole(AriaRole.Link, new() { Name = "Site Admin" })).ToBeVisibleAsync(); 30 | 31 | } 32 | 33 | // add a test that logs in and then logs out 34 | [Fact] 35 | public async Task CanLogout() 36 | { 37 | await LoginAsDefaultAdmin(); 38 | await Logout(); 39 | await Page.ScreenshotAsync(new PageScreenshotOptions() { Path = "loggedout.png" }); 40 | // check for the login link 41 | await Expect(Page.GetByRole(AriaRole.Link, new() { Name = "Login" })).ToBeVisibleAsync(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /e2e/SharpSite.E2E/FirstWebsiteTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Playwright; 2 | 3 | namespace SharpSite.E2E; 4 | 5 | public class FirstWebsiteTests : SharpSitePageTest 6 | { 7 | 8 | [Fact] 9 | public async Task HasAboutSharpSiteLink() 10 | { 11 | await Page.GotoAsync("/"); 12 | // Click the get started link. 13 | await Page.GetByRole(AriaRole.Link, new() { Name = "About SharpSite" }).ClickAsync(); 14 | 15 | // take a screenshot 16 | await Page.ScreenshotAsync(new PageScreenshotOptions() { Path = "about-sharpsite.png" }); 17 | 18 | // Expects page to have a heading with the name of Installation. 19 | await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "About SharpSite" })).ToBeVisibleAsync(); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /e2e/SharpSite.E2E/ProfileTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Playwright; 2 | 3 | namespace SharpSite.E2E; 4 | 5 | public class ProfileTests : AuthenticatedPageTests 6 | { 7 | 8 | [Fact] 9 | public async Task CanViewProfile() 10 | { 11 | 12 | await LoginAsDefaultAdmin(); 13 | 14 | await Page.GotoAsync("/Account/Manage"); 15 | await Page.ScreenshotAsync(new PageScreenshotOptions() { Path = "profile.png" }); 16 | // check for the manage profile link with the text "Site Admin" 17 | await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Manage Profile" })).ToBeVisibleAsync(); 18 | } 19 | 20 | [Fact] 21 | public async Task CanChangePhoneNumber() 22 | { 23 | 24 | await LoginAsDefaultAdmin(); 25 | 26 | // define a testPhoneNumber variable as random string of 10 digits 27 | var testPhoneNumber = Random.Shared.NextInt64(1000000000, 9999999999).ToString(); 28 | 29 | await Page.GetByLabel("Manage Profile").ClickAsync(); 30 | await Page.GetByPlaceholder("Enter your phone number").ClickAsync(); 31 | await Page.GetByPlaceholder("Enter your phone number").FillAsync(testPhoneNumber); 32 | await Page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync(); 33 | 34 | await Page.ScreenshotAsync(new PageScreenshotOptions() { Path = "profile-changedphonenumber.png" }); 35 | 36 | // check that the phone number textbox is now filled with the new number 37 | await Expect(Page.GetByPlaceholder("Enter your phone number")).ToHaveValueAsync(testPhoneNumber); 38 | 39 | } 40 | 41 | 42 | } -------------------------------------------------------------------------------- /e2e/SharpSite.E2E/SharpSite.E2E.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /e2e/SharpSite.E2E/SharpSitePageTest.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Playwright; 2 | using Microsoft.Playwright.Xunit; 3 | 4 | namespace SharpSite.E2E; 5 | 6 | public abstract class SharpSitePageTest : PageTest 7 | { 8 | 9 | public override BrowserNewContextOptions ContextOptions() 10 | { 11 | return new BrowserNewContextOptions() 12 | { 13 | ColorScheme = ColorScheme.Light, 14 | Locale = "en-US", 15 | ViewportSize = new() 16 | { 17 | // set the viewport to 1024x768 18 | Width = 1024, 19 | Height = 768, 20 | }, 21 | BaseURL = "http://localhost:5020", 22 | }; 23 | } 24 | 25 | 26 | } -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.100", 4 | "allowPrerelease": true, 5 | "rollForward": "minor" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /img/Blender/0001-0150.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/Blender/0001-0150.mp4 -------------------------------------------------------------------------------- /img/Blender/Font.md: -------------------------------------------------------------------------------- 1 | # Font used 2 | 3 | ## Where to get the Font used in the Blender-File? 4 | https://www.dafont.com/ethnocentric.font -------------------------------------------------------------------------------- /img/Blender/black_0001-0150.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/Blender/black_0001-0150.webm -------------------------------------------------------------------------------- /img/Blender/logo.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/Blender/logo.blend -------------------------------------------------------------------------------- /img/Blender/logo.blend1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/Blender/logo.blend1 -------------------------------------------------------------------------------- /img/Blender/sharpsite_org_header.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/Blender/sharpsite_org_header.blend -------------------------------------------------------------------------------- /img/Blender/sharpsite_org_header.blend1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/Blender/sharpsite_org_header.blend1 -------------------------------------------------------------------------------- /img/Blender/transparent_0001-0150.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/Blender/transparent_0001-0150.webm -------------------------------------------------------------------------------- /img/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/empty.png -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/logo.png -------------------------------------------------------------------------------- /img/logo_preview.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/logo_preview.odg -------------------------------------------------------------------------------- /img/logo_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/logo_preview.png -------------------------------------------------------------------------------- /img/logo_transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/img/logo_transparent.png -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | # plugins Folder 2 | 3 | A collection of the system plugins that ship with SharpSite -------------------------------------------------------------------------------- /plugins/Sample.FirstThemePlugin/MyTheme.cs: -------------------------------------------------------------------------------- 1 | using SharpSite.Abstractions.Theme; 2 | 3 | namespace Sample.FirstThemePlugin; 4 | 5 | public class MyTheme : IHasStylesheets 6 | { 7 | 8 | public string[] Stylesheets => [ 9 | "theme.css" 10 | ]; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /plugins/Sample.FirstThemePlugin/Sample.FirstThemePlugin.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /plugins/Sample.FirstThemePlugin/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Components.Web 2 | -------------------------------------------------------------------------------- /plugins/Sample.FirstThemePlugin/wwwroot/theme.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: blue; 3 | font-style:italic; 4 | } -------------------------------------------------------------------------------- /plugins/SharpSite.Plugins.FileStorage.FileSystem/FileSystemConfigurationSection.cs: -------------------------------------------------------------------------------- 1 | using SharpSite.Abstractions.Base; 2 | using SharpSite.Abstractions.FileStorage; 3 | using System.ComponentModel; 4 | using System.ComponentModel.DataAnnotations; 5 | 6 | namespace SharpSite.Plugins.FileStorage.FileSystem; 7 | 8 | public class FileSystemConfigurationSection : ISharpSiteConfigurationSection 9 | { 10 | public string SectionName { get; } = "FileSystem Storage"; 11 | 12 | [DisplayName("Base Folder Name"), Required, MaxLength(500)] 13 | public string BaseFolderName { get; set; } = "UploadedFiles"; 14 | 15 | public async Task OnConfigurationChanged(ISharpSiteConfigurationSection? oldConfiguration, IPluginManager pluginManager) 16 | { 17 | 18 | var oldConfig = oldConfiguration as FileSystemConfigurationSection; 19 | 20 | // check if the base folder name has changed, and if so, move the folder 21 | if (oldConfig is not null && oldConfig.BaseFolderName != BaseFolderName) 22 | { 23 | 24 | try 25 | { 26 | await pluginManager.MoveDirectoryInPluginsFolder(oldConfig.BaseFolderName, BaseFolderName); 27 | } 28 | catch (InvalidFolderException) { throw; } 29 | catch (Exception) 30 | { 31 | // typically exiting folder does not exist, so we can just create it 32 | await pluginManager.CreateDirectoryInPluginsFolder(BaseFolderName); 33 | } 34 | 35 | } 36 | else 37 | { 38 | await pluginManager.CreateDirectoryInPluginsFolder(BaseFolderName); 39 | } 40 | 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /plugins/SharpSite.Plugins.FileStorage.FileSystem/FileSystemStorage.cs: -------------------------------------------------------------------------------- 1 | using SharpSite.Abstractions.Base; 2 | using SharpSite.Abstractions.FileStorage; 3 | 4 | namespace SharpSite.Plugins.FileStorage.FileSystem; 5 | 6 | // NOTE: This is a naive and insecure implementation of file storage. It is not recommended to use this in a production environment. 7 | 8 | [RegisterPlugin(PluginServiceLocatorScope.Singleton, PluginRegisterType.FileStorage)] 9 | public partial class FileSystemStorage : IHandleFileStorage 10 | { 11 | 12 | private readonly DirectoryInfo _BaseFolder; 13 | 14 | public FileSystemConfigurationSection Configuration { get; } 15 | 16 | public FileSystemStorage( 17 | FileSystemConfigurationSection configuration, 18 | IPluginManager pluginManager) 19 | { 20 | Configuration = configuration; 21 | _BaseFolder = pluginManager.GetDirectoryInPluginsFolder(Configuration.BaseFolderName); 22 | } 23 | 24 | public async Task AddFile(FileData file) 25 | { 26 | 27 | ArgumentNullException.ThrowIfNull(file, nameof(file)); 28 | if (file.File is null || file.File.Length == 0) 29 | { 30 | throw new ArgumentException("Missing file", nameof(file)); 31 | } 32 | 33 | file.Metadata.ValidateFileName(); 34 | 35 | // Create a new file in the BaseFolder with the filename submitted 36 | var path = Path.Combine(_BaseFolder.FullName, file.Metadata.FileName); 37 | using var fileStream = File.Create(path); 38 | await file.File.CopyToAsync(fileStream); 39 | 40 | return file.Metadata.FileName; 41 | 42 | } 43 | 44 | 45 | private static object _FileReadLock = new object(); 46 | public Task GetFile(string filename) 47 | { 48 | 49 | // get the file from disk and return it with metadata 50 | var path = Path.Combine(_BaseFolder.FullName, filename); 51 | 52 | // handle a missing file by returning a placeholder 53 | if (!File.Exists(path)) return Task.FromResult(FileData.Missing); 54 | 55 | var memoryStream = new MemoryStream(); 56 | lock (_FileReadLock) 57 | { 58 | using var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); 59 | file.CopyTo(memoryStream); 60 | memoryStream.Position = 0; 61 | } 62 | 63 | // Get the content type from the file extension 64 | var contentType = MimeTypesMap.GetMimeType(Path.GetExtension(path)); 65 | var metadata = new FileMetaData(filename, contentType, File.GetCreationTime(path)); 66 | return Task.FromResult(new FileData(memoryStream, metadata)); 67 | } 68 | 69 | public Task> GetFiles(int page, int filesOnPage, out int totalFilesAvailable) 70 | { 71 | 72 | var files = Directory.GetFiles(_BaseFolder.FullName); 73 | totalFilesAvailable = files.Length; 74 | var filesOnPageArray = files.Skip((page - 1) * filesOnPage) 75 | .Take(filesOnPage) 76 | .Select(f => new FileMetaData(Path.GetFileName(f), MimeTypesMap.GetMimeType(f), File.GetCreationTime(f))); 77 | return Task.FromResult(filesOnPageArray); 78 | 79 | } 80 | 81 | public Task RemoveFile(string filename) 82 | { 83 | 84 | var path = Path.Combine(_BaseFolder.FullName, filename); 85 | File.Delete(path); 86 | return Task.CompletedTask; 87 | 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /plugins/SharpSite.Plugins.FileStorage.FileSystem/MimeTypesMap.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Plugins.FileStorage.FileSystem; 2 | 3 | public partial class FileSystemStorage 4 | { 5 | private class MimeTypesMap 6 | { 7 | internal static string GetMimeType(string fileExtension) 8 | { 9 | 10 | // implement a map of file extensions to content types 11 | // this is a very basic implementation and should be replaced with a more comprehensive solution 12 | return fileExtension switch 13 | { 14 | 15 | // add basic image types 16 | ".jpg" => "image/jpeg", 17 | ".jpeg" => "image/jpeg", 18 | ".png" => "image/png", 19 | ".gif" => "image/gif", 20 | ".bmp" => "image/bmp", 21 | ".svg" => "image/svg+xml", 22 | ".webp" => "image/webp", 23 | 24 | // add basic text types 25 | ".txt" => "text/plain", 26 | ".html" => "text/html", 27 | ".css" => "text/css", 28 | ".js" => "text/javascript", 29 | ".json" => "application/json", 30 | ".xml" => "application/xml", 31 | 32 | 33 | _ => "application/octet-stream" 34 | }; 35 | 36 | 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /plugins/SharpSite.Plugins.FileStorage.FileSystem/SharpSite.Plugins.FileStorage.FileSystem.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | ## Scripts 2 | 3 | This is a collection of scripts that help to build and run SharpSite. 4 | 5 | ### File list and purposes 6 | 7 | | File | Purpose | 8 | | --- | --- | 9 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | ## src Folder 2 | 3 | This is where the source code to run the SharpSite framework is stored -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.Base/IRegisterServices.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Hosting; 2 | 3 | namespace SharpSite.Abstractions.Base; 4 | 5 | /// 6 | /// Interface for services that need to register services with the web application. 7 | /// 8 | public interface IRegisterServices 9 | { 10 | 11 | IHostApplicationBuilder RegisterServices(IHostApplicationBuilder services, bool disableRetry = false); 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.Base/IRunAtStartup.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Abstractions.Base; 2 | 3 | /// 4 | /// Interface for services that need to run at startup of the web application. 5 | /// 6 | public interface IRunAtStartup 7 | { 8 | Task RunAtStartup(IServiceProvider services); 9 | } 10 | 11 | public interface IHasEndpoints 12 | { 13 | void MapEndpoints(IServiceProvider services); 14 | } 15 | 16 | 17 | public interface IPluginManager 18 | { 19 | 20 | Task CreateDirectoryInPluginsFolder(string name); 21 | DirectoryInfo GetDirectoryInPluginsFolder(string name); 22 | Task MoveDirectoryInPluginsFolder(string oldName, string newName); 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.Base/ISharpSiteConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Abstractions.Base; 2 | 3 | /// 4 | /// An interface for the application configuration and to access the configuration. 5 | /// 6 | public interface ISharpSiteConfiguration 7 | { 8 | 9 | /// 10 | /// A reference to the application configuration. 11 | /// 12 | Dictionary> Configuration { get; } 13 | 14 | /// 15 | /// Get the configuration for a specific section. 16 | /// 17 | /// The section of the application to get configuration settings for 18 | /// Collection of configuration settings 19 | Dictionary GetConfigurationForSection(string sectionName); 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.Base/ISharpSiteConfigurationSection.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace SharpSite.Abstractions.Base; 3 | 4 | public interface ISharpSiteConfigurationSection 5 | { 6 | 7 | string SectionName { get; } 8 | 9 | Task OnConfigurationChanged(ISharpSiteConfigurationSection? oldConfiguration, IPluginManager pluginManager); 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.Base/RegisterPluginAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Abstractions.Base; 2 | 3 | public class RegisterPluginAttribute : Attribute 4 | { 5 | public RegisterPluginAttribute(PluginServiceLocatorScope scope, PluginRegisterType registerType) 6 | { 7 | Scope = scope; 8 | RegisterType = registerType; 9 | } 10 | public PluginServiceLocatorScope Scope { get; } 11 | public PluginRegisterType RegisterType { get; } 12 | 13 | } 14 | 15 | public enum PluginServiceLocatorScope 16 | { 17 | Transient, 18 | Singleton, 19 | Scoped 20 | } 21 | 22 | public enum PluginRegisterType 23 | { 24 | FileStorage 25 | } -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.Base/SharpSite.Abstractions.Base.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | bin\Debug\ 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.FileStorage/FileData.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace SharpSite.Abstractions.FileStorage; 4 | 5 | public record FileData(Stream File, FileMetaData Metadata) 6 | { 7 | 8 | /// 9 | /// A placeholder for a missing file 10 | /// 11 | public static FileData Missing => new(Stream.Null, new FileMetaData(string.Empty, string.Empty, DateTimeOffset.MinValue)); 12 | 13 | } 14 | 15 | public record FileMetaData(string FileName, string ContentType, DateTimeOffset CreateDate) 16 | { 17 | 18 | public void ValidateFileName() 19 | { 20 | if (string.IsNullOrEmpty(FileName)) 21 | { 22 | throw new ArgumentException("Missing file name", nameof(FileName)); 23 | } 24 | 25 | // run a regular expression check to ensure the file name is valid - no slashes or other special characters 26 | var reValidFileName = new Regex(@"^[a-zA-Z0-9_\-\.]+$"); 27 | if (!reValidFileName.IsMatch(FileName)) 28 | { 29 | throw new ArgumentException("Invalid file name", nameof(FileName)); 30 | } 31 | 32 | 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.FileStorage/IHandleFileStorage.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Abstractions.FileStorage; 2 | 3 | public interface IHandleFileStorage 4 | { 5 | 6 | /// 7 | /// Get a file from storage and return it with its metadata 8 | /// 9 | /// Name of the file to fetch 10 | /// the file with metadata 11 | Task GetFile(string filename); 12 | 13 | 14 | /// 15 | /// Get a list of files from storage with metadata 16 | /// 17 | /// page number of the list of files to return 18 | /// Number of records on each page to return 19 | /// The total number of files that are available 20 | /// The selected page of file metadata 21 | Task> GetFiles(int page, int filesOnPage, out int totalFilesAvailable); 22 | 23 | /// 24 | /// Add a file to storage 25 | /// 26 | /// The file to add 27 | /// The name of the file that was added 28 | Task AddFile(FileData file); 29 | 30 | /// 31 | /// Remove a file from storage 32 | /// 33 | Task RemoveFile(string filename); 34 | 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.FileStorage/InvalidFolderException.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Abstractions.FileStorage; 2 | public class InvalidFolderException : Exception 3 | { 4 | public InvalidFolderException() : base("Invalid folder location.") { } 5 | 6 | public InvalidFolderException(string message) : base(message) { } 7 | 8 | public InvalidFolderException(string message, Exception innerException) : base(message, innerException) { } 9 | } 10 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.FileStorage/SharpSite.Abstractions.FileStorage.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.Theme/IHasStylesheets.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Abstractions.Theme; 2 | 3 | /// 4 | /// Represents part of a theme that defines the stylesheets to be included 5 | /// 6 | public interface IHasStylesheets 7 | { 8 | 9 | /// 10 | /// a collection of URLs that point to the stylesheets to be included 11 | /// 12 | string[] Stylesheets { get; } 13 | } 14 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions.Theme/SharpSite.Abstractions.Theme.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Abstractions; 2 | 3 | public static class Constants 4 | { 5 | 6 | public static class Roles 7 | { 8 | public const string AdminUsers = "Admin"; 9 | public const string Admin = "Admin"; 10 | public const string Editor = "Editor"; 11 | public const string EditorUsers = "Admin,Editor"; 12 | public const string User = "User"; 13 | public const string AllUsers = "Admin,Editor,User"; 14 | 15 | public static string[] AllRoles = [Admin, Editor, User]; 16 | 17 | } 18 | 19 | } -------------------------------------------------------------------------------- /src/SharpSite.Abstractions/IPageRepository.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | 3 | namespace SharpSite.Abstractions; 4 | 5 | public interface IPageRepository 6 | { 7 | 8 | Task AddPage(Page page); 9 | Task UpdatePage(Page page); 10 | Task DeletePage(int id); 11 | Task GetPage(string slug); 12 | Task GetPage(int id); 13 | Task> GetPages(); 14 | Task> GetPages(Expression> where); 15 | 16 | } -------------------------------------------------------------------------------- /src/SharpSite.Abstractions/IPostRepository.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | 3 | namespace SharpSite.Abstractions; 4 | 5 | public interface IPostRepository 6 | { 7 | 8 | Task GetPost(string dateString, string slug); 9 | 10 | Task> GetPosts(); 11 | 12 | Task> GetPosts(Expression> where); 13 | 14 | Task AddPost(Post post); 15 | 16 | Task UpdatePost(Post post); 17 | 18 | Task DeletePost(string slug); 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions/IUserRepository.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | 3 | namespace SharpSite.Abstractions; 4 | 5 | public interface IUserRepository 6 | { 7 | 8 | Task GetUserAsync(ClaimsPrincipal user); 9 | 10 | Task> GetAllUsersAsync(); 11 | 12 | Task UpdateRoleForUserAsync(SharpSiteUser user); 13 | 14 | } -------------------------------------------------------------------------------- /src/SharpSite.Abstractions/Page.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace SharpSite.Abstractions; 4 | 5 | public class Page 6 | { 7 | 8 | [Key] 9 | public int Id {get; set;} 10 | 11 | [Required, MinLength(4), MaxLength(100)] 12 | public string Title {get; set;} = string.Empty; 13 | 14 | [Required] 15 | public required string Slug {get; set;} 16 | 17 | public string Content {get; set;} = string.Empty; 18 | 19 | public required DateTimeOffset LastUpdate { get; set; } = DateTimeOffset.Now; 20 | 21 | [Required, MaxLength(11)] 22 | public string LanguageCode { get; set; } = "en"; 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions/Post.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace SharpSite.Abstractions; 4 | 5 | /// 6 | /// A blog post. 7 | /// 8 | public class Post 9 | { 10 | 11 | [Key, Required, MaxLength(300)] 12 | public required string Slug { get; set; } = string.Empty; 13 | 14 | [Required, MaxLength(200)] 15 | public required string Title { get; set; } = string.Empty; 16 | 17 | [MaxLength(500)] 18 | public string? Description { get; set; } 19 | 20 | [Required] 21 | public required string Content { get; set; } = string.Empty; 22 | 23 | /// 24 | /// The date the article will be published. If the date is in the future, the article will not be displayed. 25 | /// 26 | /// 27 | [Required] 28 | public DateTimeOffset PublishedDate { get; set; } = DateTimeOffset.MaxValue; 29 | 30 | [Required] 31 | public DateTimeOffset LastUpdate { get; set; } = DateTimeOffset.Now; 32 | 33 | [Required, MaxLength(11)] 34 | public string LanguageCode { get; set; } = "en"; 35 | 36 | public static string GetSlug(string title) 37 | { 38 | var slug = title.ToLower().Replace(" ", "-"); 39 | // urlencode the slug 40 | slug = System.Web.HttpUtility.UrlEncode(slug); 41 | return slug; 42 | } 43 | 44 | public Uri ToUrl() 45 | { 46 | return new Uri($"/{PublishedDate.UtcDateTime.ToString("yyyyMMdd")}/{Slug}", UriKind.Relative); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions/SharpSite.Abstractions.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | bin\Debug\ 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/SharpSite.Abstractions/SharpSiteUser.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Abstractions; 2 | 3 | public class SharpSiteUser 4 | { 5 | public SharpSiteUser(string id, string? userName, string? email) 6 | { 7 | Id = id; 8 | UserName = userName; 9 | Email = email; 10 | } 11 | 12 | public string Id { get; } 13 | public required string DisplayName { get; set; } 14 | public string? UserName { get; } 15 | public string? Email { get; } 16 | 17 | public string? PhoneNumber { get; set; } 18 | 19 | public string? Role { get; set; } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/SharpSite.AppHost/PostgresExtensions.cs: -------------------------------------------------------------------------------- 1 | public static class PostgresExtensions 2 | { 3 | 4 | /// 5 | /// A collection of version information used by the containers in this app 6 | /// 7 | public static class VERSIONS 8 | { 9 | public const string POSTGRES = "17.2"; 10 | public const string PGADMIN = "latest"; 11 | } 12 | 13 | 14 | public static 15 | (IResourceBuilder db, 16 | IResourceBuilder migrationSvc) AddPostgresServices( 17 | this IDistributedApplicationBuilder builder, 18 | bool testOnly = false) 19 | { 20 | 21 | var dbServer = builder.AddPostgres("database") 22 | .WithImageTag(VERSIONS.POSTGRES); 23 | 24 | if (!testOnly) 25 | { 26 | dbServer = dbServer.WithLifetime(ContainerLifetime.Persistent) 27 | .WithDataVolume($"{SharpSite.Data.Postgres.Constants.DBNAME}-data", false) 28 | .WithPgAdmin(config => 29 | { 30 | config.WithImageTag(VERSIONS.PGADMIN); 31 | config.WithLifetime(ContainerLifetime.Persistent); 32 | }); 33 | 34 | } 35 | else 36 | { 37 | dbServer = dbServer 38 | .WithLifetime(ContainerLifetime.Session); 39 | } 40 | 41 | var outdb = dbServer.AddDatabase(SharpSite.Data.Postgres.Constants.DBNAME); 42 | 43 | var migrationSvc = builder.AddProject($"{SharpSite.Data.Postgres.Constants.DBNAME}migrationsvc") 44 | .WithReference(outdb) 45 | .WaitFor(dbServer); 46 | 47 | return (outdb, migrationSvc); 48 | 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/SharpSite.AppHost/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = DistributedApplication.CreateBuilder(args); 2 | 3 | var testOnly = false; 4 | 5 | foreach (var arg in args) 6 | { 7 | if (arg.StartsWith("--testonly")) 8 | { 9 | var parts = arg.Split('='); 10 | if (parts.Length == 2 && bool.TryParse(parts[1], out var result)) 11 | { 12 | testOnly = result; 13 | } 14 | } 15 | } 16 | 17 | var (db, migrationSvc) = builder.AddPostgresServices(testOnly); 18 | 19 | builder.AddProject("webfrontend") 20 | .WithReference(db) 21 | .WaitForCompletion(migrationSvc) 22 | .WithRunE2eTestsCommand() 23 | .WithExternalHttpEndpoints(); 24 | 25 | if (testOnly) 26 | { 27 | // start the site with runasync and watch for a file to be created called 'stop-aspire' 28 | // to stop the site 29 | var theSite = builder.Build(); 30 | var fileSystemWatcher = new FileSystemWatcher(".", "stop-aspire") 31 | { 32 | NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime 33 | }; 34 | 35 | fileSystemWatcher.Created += async (sender, e) => 36 | { 37 | if (e.Name == "stop-aspire") 38 | { 39 | Console.WriteLine("Stopping the site"); 40 | await theSite.StopAsync(); 41 | fileSystemWatcher.Dispose(); 42 | } 43 | }; 44 | 45 | fileSystemWatcher.EnableRaisingEvents = true; 46 | 47 | Console.WriteLine("Starting the site in test mode"); 48 | await theSite.RunAsync(); 49 | 50 | } 51 | else 52 | { 53 | builder.Build().Run(); 54 | 55 | } 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/SharpSite.AppHost/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "https": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "applicationUrl": "https://localhost:17270;http://localhost:15200", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development", 11 | "DOTNET_ENVIRONMENT": "Development", 12 | "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21152", 13 | "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22241" 14 | } 15 | }, 16 | "http": { 17 | "commandName": "Project", 18 | "dotnetRunMessages": true, 19 | "launchBrowser": true, 20 | "applicationUrl": "http://localhost:15200", 21 | "environmentVariables": { 22 | "ASPNETCORE_ENVIRONMENT": "Development", 23 | "DOTNET_ENVIRONMENT": "Development", 24 | "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19001", 25 | "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20137" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/SharpSite.AppHost/RunE2ETestsCommand.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Diagnostics.HealthChecks; 3 | using Microsoft.Extensions.Logging; 4 | using System.Diagnostics; 5 | 6 | public static class RunE2ETestsCommand 7 | { 8 | public const string Name = "run-e2e-tests"; 9 | 10 | public static IResourceBuilder WithRunE2eTestsCommand( 11 | this IResourceBuilder builder) 12 | { 13 | builder.WithCommand( 14 | name: Name, 15 | displayName: "Run end to end tests", 16 | executeCommand: context => RunTests(), 17 | updateState: OnUpdateResourceState, 18 | iconName: "BookGlobe", 19 | iconVariant: IconVariant.Filled); 20 | 21 | return builder; 22 | } 23 | 24 | 25 | private static async Task RunTests() 26 | { 27 | var processStartInfo = new ProcessStartInfo 28 | { 29 | FileName = "dotnet", 30 | Arguments = "test ../../e2e/SharpSite.E2E", 31 | RedirectStandardOutput = true, 32 | RedirectStandardError = true, 33 | UseShellExecute = false, 34 | CreateNoWindow = true 35 | }; 36 | 37 | var process = new Process { StartInfo = processStartInfo }; 38 | process.Start(); 39 | 40 | var output = await process.StandardOutput.ReadToEndAsync(); 41 | var error = await process.StandardError.ReadToEndAsync(); 42 | 43 | process.WaitForExit(); 44 | Console.WriteLine("E2E Tests Output: " + output); 45 | 46 | if (process.ExitCode == 0) 47 | { 48 | return new ExecuteCommandResult() { Success = true }; 49 | } 50 | else 51 | { 52 | return new ExecuteCommandResult() { Success = false, ErrorMessage = error }; 53 | } 54 | } 55 | 56 | private static ResourceCommandState OnUpdateResourceState( 57 | UpdateCommandStateContext context) 58 | { 59 | var logger = context.ServiceProvider.GetRequiredService>(); 60 | 61 | //if (logger.IsEnabled(LogLevel.Information)) 62 | //{ 63 | // logger.LogInformation( 64 | // "Updating resource state: {ResourceSnapshot}", 65 | // context.ResourceSnapshot); 66 | //} 67 | 68 | return context.ResourceSnapshot.HealthStatus is HealthStatus.Healthy 69 | ? ResourceCommandState.Enabled 70 | : ResourceCommandState.Disabled; 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/SharpSite.AppHost/SharpSite.AppHost.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net9.0 6 | enable 7 | enable 8 | true 9 | 87040d9b-cd38-4531-b636-55bfacdceff5 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/SharpSite.AppHost/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/SharpSite.AppHost/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Aspire.Hosting.Dcp": "Warning" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres.Migration/Program.cs: -------------------------------------------------------------------------------- 1 | using SharpSite.Data.Postgres; 2 | using SharpSite.Data.Postgres.Migration; 3 | using SharpSite.Security.Postgres; 4 | 5 | var builder = Host.CreateApplicationBuilder(args); 6 | 7 | builder.AddServiceDefaults(); 8 | var pg = new RegisterPostgresServices(); 9 | pg.RegisterServices(builder, disableRetry: true); 10 | 11 | RegisterPostgresSecurityServices.ConfigurePostgresDbContext(builder, disableRetry: true); 12 | 13 | builder.Services.AddHostedService(); 14 | 15 | 16 | builder.Services.AddOpenTelemetry() 17 | .WithTracing(tracing => tracing.AddSource(Worker.ActivitySourceName)); 18 | 19 | var host = builder.Build(); 20 | host.Run(); 21 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres.Migration/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "SharpSite.Data.Postgres.Migration": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "environmentVariables": { 8 | "DOTNET_ENVIRONMENT": "Development" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres.Migration/SharpSite.Data.Postgres.Migration.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | dotnet-SharpSite.Data.Postgres.Migration-289ae9dd-798a-46ac-a8f2-306177566084 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres.Migration/Worker.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Infrastructure; 3 | using Microsoft.EntityFrameworkCore.Storage; 4 | using SharpSite.Security.Postgres; 5 | using System.Diagnostics; 6 | 7 | namespace SharpSite.Data.Postgres.Migration; 8 | 9 | public class Worker( 10 | IServiceProvider serviceProvider, 11 | IHostApplicationLifetime hostApplicationLifetime) : BackgroundService 12 | { 13 | public const string ActivitySourceName = "Migrations"; 14 | private static readonly ActivitySource s_activitySource = new(ActivitySourceName); 15 | 16 | protected override async Task ExecuteAsync(CancellationToken cancellationToken) 17 | { 18 | using (var activity = s_activitySource.StartActivity("Migrating website database", ActivityKind.Client)) 19 | { 20 | 21 | try 22 | { 23 | using var scope = serviceProvider.CreateScope(); 24 | var dbContext = scope.ServiceProvider.GetRequiredService(); 25 | 26 | await EnsureDatabaseAsync(dbContext, cancellationToken); 27 | await RunMigrationAsync(dbContext, cancellationToken); 28 | 29 | } 30 | catch (Exception ex) 31 | { 32 | activity?.AddException(ex); 33 | throw; 34 | } 35 | 36 | } 37 | 38 | using (var activity = s_activitySource.StartActivity("Migrating security database", ActivityKind.Client)) 39 | { 40 | 41 | try 42 | { 43 | using var scope = serviceProvider.CreateScope(); 44 | var dbContext = scope.ServiceProvider.GetRequiredService(); 45 | 46 | await EnsureDatabaseAsync(dbContext, cancellationToken); 47 | await RunMigrationAsync(dbContext, cancellationToken); 48 | 49 | } 50 | catch (Exception ex) 51 | { 52 | activity?.AddException(ex); 53 | throw; 54 | } 55 | 56 | } 57 | 58 | hostApplicationLifetime.StopApplication(); 59 | 60 | } 61 | 62 | private static async Task EnsureDatabaseAsync(DbContext dbContext, CancellationToken cancellationToken) 63 | { 64 | var dbCreator = dbContext.GetService(); 65 | 66 | if (!await dbCreator.ExistsAsync(cancellationToken)) 67 | { 68 | await dbCreator.CreateAsync(cancellationToken); 69 | } 70 | 71 | } 72 | 73 | private static async Task RunMigrationAsync(DbContext dbContext, CancellationToken cancellationToken) 74 | { 75 | 76 | // Run migration in a transaction to avoid partial migration if it fails. 77 | //await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); 78 | await dbContext.Database.MigrateAsync(cancellationToken); 79 | //await transaction.CommitAsync(cancellationToken); 80 | 81 | } 82 | 83 | 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres.Migration/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.Hosting.Lifetime": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres.Migration/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.Hosting.Lifetime": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20241015163007_Initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using SharpSite.Data.Postgres; 9 | 10 | #nullable disable 11 | 12 | namespace SharpSite.Data.Postgres.Migrations 13 | { 14 | [DbContext(typeof(PgContext))] 15 | [Migration("20241015163007_Initial")] 16 | partial class Initial 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "8.0.10") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("SharpSite.Data.Postgres.PgPost", b => 29 | { 30 | b.Property("Slug") 31 | .HasColumnType("text"); 32 | 33 | b.Property("Content") 34 | .IsRequired() 35 | .HasColumnType("text"); 36 | 37 | b.Property("Published") 38 | .HasColumnType("timestamp with time zone"); 39 | 40 | b.Property("Title") 41 | .IsRequired() 42 | .HasMaxLength(300) 43 | .HasColumnType("character varying(300)"); 44 | 45 | b.HasKey("Slug"); 46 | 47 | b.ToTable("Posts"); 48 | }); 49 | #pragma warning restore 612, 618 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20241015163007_Initial.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace SharpSite.Data.Postgres.Migrations 7 | { 8 | /// 9 | public partial class Initial : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.CreateTable( 15 | name: "Posts", 16 | columns: table => new 17 | { 18 | Slug = table.Column(type: "text", nullable: false), 19 | Title = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), 20 | Content = table.Column(type: "text", nullable: false), 21 | Published = table.Column(type: "timestamp with time zone", nullable: false) 22 | }, 23 | constraints: table => 24 | { 25 | table.PrimaryKey("PK_Posts", x => x.Slug); 26 | }); 27 | } 28 | 29 | /// 30 | protected override void Down(MigrationBuilder migrationBuilder) 31 | { 32 | migrationBuilder.DropTable( 33 | name: "Posts"); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20241017152941_Maxlength for slug.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using SharpSite.Data.Postgres; 9 | 10 | #nullable disable 11 | 12 | namespace SharpSite.Data.Postgres.Migrations 13 | { 14 | [DbContext(typeof(PgContext))] 15 | [Migration("20241017152941_Maxlength for slug")] 16 | partial class Maxlengthforslug 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "8.0.10") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("SharpSite.Data.Postgres.PgPost", b => 29 | { 30 | b.Property("Slug") 31 | .HasColumnType("text"); 32 | 33 | b.Property("Content") 34 | .IsRequired() 35 | .HasColumnType("text"); 36 | 37 | b.Property("Published") 38 | .HasColumnType("timestamp with time zone"); 39 | 40 | b.Property("Title") 41 | .IsRequired() 42 | .HasMaxLength(300) 43 | .HasColumnType("character varying(300)"); 44 | 45 | b.HasKey("Slug"); 46 | 47 | b.ToTable("Posts"); 48 | }); 49 | #pragma warning restore 612, 618 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20241017152941_Maxlength for slug.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace SharpSite.Data.Postgres.Migrations 6 | { 7 | /// 8 | public partial class Maxlengthforslug : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | 14 | } 15 | 16 | /// 17 | protected override void Down(MigrationBuilder migrationBuilder) 18 | { 19 | 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20241017153401_Maxlength for slug part 2.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using SharpSite.Data.Postgres; 9 | 10 | #nullable disable 11 | 12 | namespace SharpSite.Data.Postgres.Migrations 13 | { 14 | [DbContext(typeof(PgContext))] 15 | [Migration("20241017153401_Maxlength for slug part 2")] 16 | partial class Maxlengthforslugpart2 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "8.0.10") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("SharpSite.Data.Postgres.PgPost", b => 29 | { 30 | b.Property("Slug") 31 | .HasMaxLength(300) 32 | .HasColumnType("character varying(300)"); 33 | 34 | b.Property("Content") 35 | .IsRequired() 36 | .HasColumnType("text"); 37 | 38 | b.Property("Published") 39 | .HasColumnType("timestamp with time zone"); 40 | 41 | b.Property("Title") 42 | .IsRequired() 43 | .HasMaxLength(200) 44 | .HasColumnType("character varying(200)"); 45 | 46 | b.HasKey("Slug"); 47 | 48 | b.ToTable("Posts"); 49 | }); 50 | #pragma warning restore 612, 618 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20241017153401_Maxlength for slug part 2.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace SharpSite.Data.Postgres.Migrations 6 | { 7 | /// 8 | public partial class Maxlengthforslugpart2 : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.AlterColumn( 14 | name: "Title", 15 | table: "Posts", 16 | type: "character varying(200)", 17 | maxLength: 200, 18 | nullable: false, 19 | oldClrType: typeof(string), 20 | oldType: "character varying(300)", 21 | oldMaxLength: 300); 22 | 23 | migrationBuilder.AlterColumn( 24 | name: "Slug", 25 | table: "Posts", 26 | type: "character varying(300)", 27 | maxLength: 300, 28 | nullable: false, 29 | oldClrType: typeof(string), 30 | oldType: "text"); 31 | } 32 | 33 | /// 34 | protected override void Down(MigrationBuilder migrationBuilder) 35 | { 36 | migrationBuilder.AlterColumn( 37 | name: "Title", 38 | table: "Posts", 39 | type: "character varying(300)", 40 | maxLength: 300, 41 | nullable: false, 42 | oldClrType: typeof(string), 43 | oldType: "character varying(200)", 44 | oldMaxLength: 200); 45 | 46 | migrationBuilder.AlterColumn( 47 | name: "Slug", 48 | table: "Posts", 49 | type: "text", 50 | nullable: false, 51 | oldClrType: typeof(string), 52 | oldType: "character varying(300)", 53 | oldMaxLength: 300); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20241029161246_Add description to post.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using SharpSite.Data.Postgres; 9 | 10 | #nullable disable 11 | 12 | namespace SharpSite.Data.Postgres.Migrations 13 | { 14 | [DbContext(typeof(PgContext))] 15 | [Migration("20241029161246_Add description to post")] 16 | partial class Adddescriptiontopost 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "8.0.10") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("SharpSite.Data.Postgres.PgPost", b => 29 | { 30 | b.Property("Slug") 31 | .HasMaxLength(300) 32 | .HasColumnType("character varying(300)"); 33 | 34 | b.Property("Content") 35 | .IsRequired() 36 | .HasColumnType("text"); 37 | 38 | b.Property("Description") 39 | .HasMaxLength(500) 40 | .HasColumnType("character varying(500)"); 41 | 42 | b.Property("Published") 43 | .HasColumnType("timestamp with time zone"); 44 | 45 | b.Property("Title") 46 | .IsRequired() 47 | .HasMaxLength(200) 48 | .HasColumnType("character varying(200)"); 49 | 50 | b.HasKey("Slug"); 51 | 52 | b.ToTable("Posts"); 53 | }); 54 | #pragma warning restore 612, 618 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20241029161246_Add description to post.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace SharpSite.Data.Postgres.Migrations 6 | { 7 | /// 8 | public partial class Adddescriptiontopost : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.AddColumn( 14 | name: "Description", 15 | table: "Posts", 16 | type: "character varying(500)", 17 | maxLength: 500, 18 | nullable: true); 19 | } 20 | 21 | /// 22 | protected override void Down(MigrationBuilder migrationBuilder) 23 | { 24 | migrationBuilder.DropColumn( 25 | name: "Description", 26 | table: "Posts"); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20241031151541_Add Page to database.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 3 | 4 | #nullable disable 5 | 6 | namespace SharpSite.Data.Postgres.Migrations 7 | { 8 | /// 9 | public partial class AddPagetodatabase : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.CreateTable( 15 | name: "Pages", 16 | columns: table => new 17 | { 18 | Id = table.Column(type: "integer", nullable: false) 19 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 20 | Title = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), 21 | Slug = table.Column(type: "text", nullable: false), 22 | Content = table.Column(type: "text", nullable: false) 23 | }, 24 | constraints: table => 25 | { 26 | table.PrimaryKey("PK_Pages", x => x.Id); 27 | }); 28 | 29 | migrationBuilder.CreateIndex( 30 | name: "IX_Pages_Slug", 31 | table: "Pages", 32 | column: "Slug", 33 | unique: true); 34 | } 35 | 36 | /// 37 | protected override void Down(MigrationBuilder migrationBuilder) 38 | { 39 | migrationBuilder.DropTable( 40 | name: "Pages"); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20241127172544_Add Page LastUpdate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace SharpSite.Data.Postgres.Migrations 7 | { 8 | /// 9 | public partial class AddPageLastUpdate : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.AddColumn( 15 | name: "LastUpdate", 16 | table: "Pages", 17 | type: "timestamp with time zone", 18 | nullable: false, 19 | defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); 20 | } 21 | 22 | /// 23 | protected override void Down(MigrationBuilder migrationBuilder) 24 | { 25 | migrationBuilder.DropColumn( 26 | name: "LastUpdate", 27 | table: "Pages"); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20241210172309_PostLastUpdate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace SharpSite.Data.Postgres.Migrations 7 | { 8 | /// 9 | public partial class PostLastUpdate : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.AddColumn( 15 | name: "LastUpdate", 16 | table: "Posts", 17 | type: "timestamp with time zone", 18 | nullable: false, 19 | defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); 20 | } 21 | 22 | /// 23 | protected override void Down(MigrationBuilder migrationBuilder) 24 | { 25 | migrationBuilder.DropColumn( 26 | name: "LastUpdate", 27 | table: "Posts"); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/Migrations/20250101182712_AddLanguageCodeToPostsAndPages.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace SharpSite.Data.Postgres.Migrations 6 | { 7 | /// 8 | public partial class AddLanguageCodeToPostsAndPages : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.AddColumn( 14 | name: "LanguageCode", 15 | table: "Posts", 16 | type: "character varying(11)", 17 | maxLength: 11, 18 | nullable: false, 19 | defaultValue: ""); 20 | 21 | migrationBuilder.AddColumn( 22 | name: "LanguageCode", 23 | table: "Pages", 24 | type: "character varying(11)", 25 | maxLength: 11, 26 | nullable: false, 27 | defaultValue: ""); 28 | 29 | // Update existing rows with default value "en" 30 | migrationBuilder.Sql("UPDATE \"Posts\" SET \"LanguageCode\" = 'en' WHERE \"LanguageCode\" = ''"); 31 | migrationBuilder.Sql("UPDATE \"Pages\" SET \"LanguageCode\" = 'en' WHERE \"LanguageCode\" = ''"); 32 | } 33 | 34 | /// 35 | protected override void Down(MigrationBuilder migrationBuilder) 36 | { 37 | migrationBuilder.DropColumn( 38 | name: "LanguageCode", 39 | table: "Posts"); 40 | 41 | migrationBuilder.DropColumn( 42 | name: "LanguageCode", 43 | table: "Pages"); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/PgContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 3 | 4 | namespace SharpSite.Data.Postgres; 5 | 6 | public class PgContext : DbContext 7 | { 8 | 9 | public PgContext(DbContextOptions options) : base(options) { } 10 | 11 | public DbSet Pages => Set(); 12 | 13 | public DbSet Posts => Set(); 14 | 15 | protected override void OnModelCreating(ModelBuilder modelBuilder) 16 | { 17 | 18 | modelBuilder.Entity() 19 | .HasIndex(p => p.Slug) 20 | .IsUnique(); 21 | 22 | modelBuilder 23 | .Entity() 24 | .Property(e => e.Published) 25 | .HasConversion(new DateTimeOffsetConverter()); 26 | 27 | 28 | modelBuilder 29 | .Entity() 30 | .Property(e => e.LastUpdate) 31 | .HasConversion(new DateTimeOffsetConverter()); 32 | 33 | modelBuilder 34 | .Entity() 35 | .Property(e => e.LastUpdate) 36 | .HasConversion(new DateTimeOffsetConverter()); 37 | 38 | } 39 | 40 | } 41 | 42 | public class DateTimeOffsetConverter : ValueConverter 43 | { 44 | public DateTimeOffsetConverter() : base( 45 | v => v.UtcDateTime, 46 | v => v) 47 | { 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/PgPage.cs: -------------------------------------------------------------------------------- 1 | using SharpSite.Abstractions; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace SharpSite.Data.Postgres; 5 | 6 | public class PgPage 7 | { 8 | 9 | [Key] 10 | public int Id {get; set;} 11 | 12 | [Required, MinLength(4), MaxLength(100)] 13 | public string Title {get; set;} = string.Empty; 14 | 15 | [Required] 16 | public required string Slug {get; set;} 17 | 18 | public string Content {get; set;} = string.Empty; 19 | 20 | public DateTimeOffset LastUpdate { get; set; } = DateTimeOffset.Now; 21 | 22 | 23 | [Required, MaxLength(11)] 24 | public string LanguageCode { get; set; } = "en"; 25 | 26 | public static explicit operator PgPage(Page page) 27 | { 28 | 29 | return new PgPage 30 | { 31 | Id = page.Id, 32 | Title = page.Title, 33 | Slug = page.Slug, 34 | Content = page.Content, 35 | LastUpdate = page.LastUpdate, 36 | LanguageCode = page.LanguageCode 37 | }; 38 | 39 | } 40 | 41 | public static explicit operator Page(PgPage page) 42 | { 43 | return new Page 44 | { 45 | Id = page.Id, 46 | Title = page.Title, 47 | Slug = page.Slug, 48 | Content = page.Content, 49 | LastUpdate = page.LastUpdate, 50 | LanguageCode = page.LanguageCode 51 | }; 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/PgPost.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace SharpSite.Data.Postgres; 4 | 5 | /// 6 | /// A postgres specific implementation of a post. 7 | /// 8 | public class PgPost 9 | { 10 | 11 | [Required, Key, MaxLength(300)] 12 | public required string Slug { get; set; } 13 | 14 | [Required, MaxLength(200)] 15 | public required string Title { get; set; } 16 | 17 | [MaxLength(500)] 18 | public string? Description { get; set; } 19 | 20 | [Required] 21 | public required string Content { get; set; } = string.Empty; 22 | 23 | [Required] 24 | public required DateTimeOffset Published { get; set; } = DateTimeOffset.MaxValue; 25 | 26 | [Required] 27 | public required DateTimeOffset LastUpdate { get; set; } = DateTimeOffset.Now; 28 | 29 | [Required, MaxLength(11)] 30 | public string LanguageCode { get; set; } = "en"; 31 | 32 | public static explicit operator PgPost(SharpSite.Abstractions.Post post) 33 | { 34 | 35 | return new PgPost 36 | { 37 | Slug = post.Slug, 38 | Title = post.Title, 39 | Description = post.Description, 40 | Content = post.Content, 41 | Published = post.PublishedDate, 42 | LastUpdate = post.LastUpdate, 43 | LanguageCode = post.LanguageCode, 44 | }; 45 | 46 | } 47 | 48 | public static explicit operator SharpSite.Abstractions.Post(PgPost post) 49 | { 50 | 51 | return new SharpSite.Abstractions.Post 52 | { 53 | Slug = post.Slug, 54 | Title = post.Title, 55 | Description = post.Description, 56 | Content = post.Content, 57 | PublishedDate = post.Published, 58 | LastUpdate = post.LastUpdate, 59 | LanguageCode = post.LanguageCode, 60 | }; 61 | 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/PgPostRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using SharpSite.Abstractions; 4 | using System.Globalization; 5 | using System.Linq.Expressions; 6 | 7 | namespace SharpSite.Data.Postgres; 8 | 9 | public class PgPostRepository : IPostRepository 10 | { 11 | 12 | public PgPostRepository(IServiceProvider serviceProvider) 13 | { 14 | Context = serviceProvider.CreateScope().ServiceProvider.GetRequiredService(); 15 | } 16 | 17 | private readonly PgContext Context; 18 | 19 | public async Task AddPost(Post post) 20 | { 21 | // add a post to the database 22 | post.PublishedDate = DateTimeOffset.Now; 23 | post.LastUpdate = DateTimeOffset.Now; 24 | await Context.Posts.AddAsync((PgPost)post); 25 | await Context.SaveChangesAsync(); 26 | 27 | return post; 28 | } 29 | 30 | public async Task DeletePost(string slug) 31 | { 32 | // delete a post from the database based on the slug submitted 33 | var post = await Context.Posts.FirstOrDefaultAsync(p => p.Slug == slug); 34 | if (post != null) 35 | { 36 | Context.Posts.Remove(post); 37 | await Context.SaveChangesAsync(); 38 | } 39 | } 40 | 41 | public async Task GetPost(string dateString, string slug) 42 | { 43 | 44 | if (string.IsNullOrEmpty(dateString) || string.IsNullOrEmpty(slug)) 45 | { 46 | return null; 47 | } 48 | 49 | var theDate = DateTimeOffset.ParseExact(dateString, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); 50 | 51 | // get a post from the database based on the slug submitted 52 | var thePosts = await Context.Posts 53 | .AsNoTracking() 54 | .Where(p => p.Slug == slug) 55 | .Select(p => (Post)p) 56 | .ToArrayAsync(); 57 | 58 | return thePosts.FirstOrDefault(p => 59 | p.PublishedDate.UtcDateTime.Date == theDate.UtcDateTime.Date); 60 | 61 | } 62 | 63 | public async Task> GetPosts() 64 | { 65 | // get all posts from the database 66 | var posts = await Context.Posts.AsNoTracking().ToArrayAsync(); 67 | return posts.Select(p => (Post)p); 68 | } 69 | 70 | public async Task> GetPosts(Expression> where) 71 | { 72 | // get all posts from the database based on the where clause 73 | return await Context.Posts 74 | .AsNoTracking() 75 | .Where(p => where.Compile().Invoke((Post)p)) 76 | .Select(p => (Post)p) 77 | .ToArrayAsync(); 78 | 79 | } 80 | 81 | public async Task UpdatePost(Post post) 82 | { 83 | // update a post in the database 84 | post.LastUpdate = DateTimeOffset.Now; 85 | Context.Posts.Update((PgPost)post); 86 | await Context.SaveChangesAsync(); 87 | 88 | return post; 89 | 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/RegisterPostgresServices.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Hosting; 3 | using SharpSite.Abstractions; 4 | using SharpSite.Abstractions.Base; 5 | 6 | namespace SharpSite.Data.Postgres; 7 | 8 | public class RegisterPostgresServices : IRegisterServices 9 | { 10 | public IHostApplicationBuilder RegisterServices(IHostApplicationBuilder host, bool disableRetry = false) 11 | { 12 | 13 | host.Services.AddTransient(); 14 | host.Services.AddTransient(); 15 | host.AddNpgsqlDbContext(Constants.DBNAME, configure => 16 | { 17 | configure.DisableRetry = disableRetry; 18 | }); 19 | 20 | return host; 21 | 22 | } 23 | } 24 | 25 | public static class Constants 26 | { 27 | 28 | public const string DBNAME = "SharpSite"; 29 | 30 | } -------------------------------------------------------------------------------- /src/SharpSite.Data.Postgres/SharpSite.Data.Postgres.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | net9.0 13 | enable 14 | enable 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/SharpSite.Plugins/Plugin.cs: -------------------------------------------------------------------------------- 1 | using SharpSite.Abstractions; 2 | 3 | namespace SharpSite.Plugins; 4 | 5 | public class Plugin(MemoryStream stream, string pluginName) 6 | { 7 | public readonly string Name = pluginName; 8 | 9 | public readonly byte[] Bytes = stream.ToArray(); 10 | 11 | public static async Task LoadFromStream(Stream pluginContentStream, string pluginName) 12 | { 13 | using var pluginMemoryStream = new MemoryStream(); 14 | await pluginContentStream.CopyToAsync(pluginMemoryStream); 15 | return new Plugin(pluginMemoryStream, pluginName); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/SharpSite.Plugins/PluginAssembly.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components; 2 | using SharpSite.Abstractions; 3 | using System.Reflection; 4 | using System.Runtime.Loader; 5 | 6 | namespace SharpSite.Plugins; 7 | 8 | public class PluginAssembly 9 | { 10 | private readonly Plugin _plugin; 11 | private readonly PluginManifest _pluginMainfest; 12 | private PluginAssemblyLoadContext? _loadContext; 13 | private Assembly? _assembly; 14 | 15 | public Assembly? Assembly => _assembly; 16 | 17 | public PluginManifest Manifest => _pluginMainfest; 18 | 19 | public PluginAssembly(PluginManifest pluginMainfest, Plugin plugin) 20 | { 21 | _plugin = plugin; 22 | _pluginMainfest = pluginMainfest; 23 | } 24 | 25 | public void LoadContext() 26 | { 27 | if (_loadContext != null) return; 28 | _loadContext = new PluginAssemblyLoadContext(); 29 | _assembly = _loadContext.Load(_plugin.Bytes); 30 | } 31 | 32 | public void UnloadContext() 33 | { 34 | if (_loadContext == null) return; 35 | _loadContext.Unload(); 36 | _loadContext = null; 37 | GC.Collect(); 38 | GC.WaitForPendingFinalizers(); 39 | } 40 | } -------------------------------------------------------------------------------- /src/SharpSite.Plugins/PluginAssemblyLoadContext.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.Loader; 3 | 4 | namespace SharpSite.Plugins; 5 | 6 | public class PluginAssemblyLoadContext : AssemblyLoadContext 7 | { 8 | public PluginAssemblyLoadContext() : base(isCollectible: true) { } 9 | 10 | public Assembly Load(byte[] assemblyData) 11 | { 12 | using (var ms = new MemoryStream(assemblyData)) 13 | { 14 | return LoadFromStream(ms); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/SharpSite.Plugins/PluginAssemblyManager.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components; 2 | using Microsoft.Extensions.Logging; 3 | using System.Reflection; 4 | 5 | namespace SharpSite.Plugins; 6 | 7 | public class PluginAssemblyManager(ILogger logger): IDisposable 8 | { 9 | private readonly ILogger _logger = logger; 10 | 11 | private bool disposed = false; 12 | private readonly Dictionary _pluginAssemblies = new Dictionary(); 13 | 14 | public IReadOnlyDictionary Assemblies => _pluginAssemblies; 15 | 16 | public void AddAssembly(PluginAssembly assembly) 17 | { 18 | _logger.LogInformation("Assembly {AssemblyManifestId} being added", assembly.Manifest.Id); 19 | if (!_pluginAssemblies.ContainsKey(assembly.Manifest.Id)) 20 | { 21 | _logger.LogInformation("Plugins does not have plugin assenbly with id {AssemblyManifestId}", assembly.Manifest.Id); 22 | _pluginAssemblies.Add(assembly.Manifest.Id, assembly); 23 | 24 | } 25 | else 26 | { 27 | _logger.LogInformation("Plugins does have plugin assenbly with id {AssemblyManifestId}", assembly.Manifest.Id); 28 | _pluginAssemblies[assembly.Manifest.Id].UnloadContext(); 29 | _pluginAssemblies[assembly.Manifest.Id] = assembly; 30 | } 31 | assembly.LoadContext(); 32 | } 33 | 34 | public void RemoveAssembly(PluginAssembly assembly) 35 | { 36 | if (_pluginAssemblies.ContainsKey(assembly.Manifest.Id)) 37 | { 38 | assembly.UnloadContext(); 39 | _pluginAssemblies.Remove(assembly.Manifest.Id); 40 | } 41 | } 42 | 43 | protected virtual void Dispose(bool disposing) 44 | { 45 | if (!disposed) 46 | { 47 | foreach(var pluginAssembly in _pluginAssemblies.Values) 48 | { 49 | pluginAssembly.UnloadContext(); 50 | } 51 | disposed = true; 52 | } 53 | } 54 | 55 | public void Dispose() 56 | { 57 | // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 58 | Dispose(disposing: true); 59 | GC.SuppressFinalize(this); 60 | } 61 | } -------------------------------------------------------------------------------- /src/SharpSite.Plugins/PluginException.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.Serialization; 2 | 3 | namespace SharpSite.Plugins; 4 | 5 | public class PluginException : Exception 6 | { 7 | public PluginException() { } 8 | public PluginException(string message) : base(message) { } 9 | public PluginException(Exception exception, string message) : base(message, exception) { } 10 | } 11 | -------------------------------------------------------------------------------- /src/SharpSite.Plugins/PluginManifest.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace SharpSite.Plugins; 4 | 5 | public class PluginManifest 6 | { 7 | [JsonPropertyName("id")] 8 | public required string Id { get; set; } 9 | public required string DisplayName { get; set; } 10 | public required string Description { get; set; } 11 | public required string Version { get; set; } 12 | public string? Icon { get; set; } 13 | public required string Published { get; set; } 14 | public required string SupportedVersions { get; set; } 15 | public required string Author { get; set; } 16 | public required string Contact { get; set; } 17 | public required string ContactEmail { get; set; } 18 | public required string AuthorWebsite { get; set; } 19 | public string? Source { get; set; } 20 | public string? KnownLicense { get; set; } 21 | public string[]? Tags { get; set; } 22 | public required PluginFeatures[] Features { get; set; } 23 | 24 | public string IdVersionToString() 25 | { 26 | return $"{Id}@{Version}"; 27 | } 28 | 29 | } 30 | 31 | public enum PluginFeatures 32 | { 33 | Theme, 34 | FileStorage 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/SharpSite.Plugins/PluginManifestExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using System.Text.RegularExpressions; 3 | 4 | namespace SharpSite.Plugins; 5 | 6 | public static class PluginManifestExtensions 7 | { 8 | /// 9 | /// Provides PluginManifest extensions for mainfest validaion 10 | /// 11 | /// 12 | /// 13 | /// Raises 14 | public static void ValidateManifest(this PluginManifest manifest, ILogger logger, Plugin plugin) 15 | { 16 | if (manifest == null) return; 17 | 18 | // check for a valid version number, valid plugin Id, etc 19 | if (string.IsNullOrEmpty(manifest.Id)) 20 | { 21 | logger.LogError("Invalid plugin manifest: {FileName}", plugin.Name); 22 | throw new PluginException("Plugin manifest is missing a valid Id."); 23 | } 24 | 25 | // manifest Id should only contain letters, numbers, period, hyphen, and underscore 26 | if (!Regex.IsMatch(manifest.Id, @"^[a-zA-Z0-9\.\-_]+$")) 27 | { 28 | logger.LogError("Invalid plugin manifest: {FileName}", plugin.Name); 29 | throw new PluginException("Plugin manifest Id contains invalid characters."); 30 | } 31 | 32 | // manifest version should only contain letters, numbers, period, hyphen, and underscore 33 | if (!Regex.IsMatch(manifest.Version, @"^[a-zA-Z0-9\.\-_]+$")) 34 | { 35 | logger.LogError("Invalid plugin manifest: {FileName}", plugin.Name); 36 | throw new PluginException("Plugin manifest version contains invalid characters."); 37 | } 38 | 39 | if (string.IsNullOrEmpty(manifest.DisplayName)) 40 | { 41 | logger.LogError("Invalid plugin manifest: {FileName}", plugin.Name); 42 | throw new PluginException("Plugin manifest is missing a valid DisplayName."); 43 | } 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /src/SharpSite.Plugins/SharpSite.Plugins.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/AccessDenied.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/AccessDenied" 2 | 3 | Access denied 4 | 5 |
6 |

Access denied

7 |

You do not have access to this resource.

8 |
9 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/ConfirmEmail.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ConfirmEmail" 2 | 3 | @using System.Text 4 | @using Microsoft.AspNetCore.Identity 5 | @using Microsoft.AspNetCore.WebUtilities 6 | 7 | @inject UserManager UserManager 8 | @inject IdentityRedirectManager RedirectManager 9 | 10 | Confirm email 11 | 12 |

Confirm email

13 | 14 | 15 | @code { 16 | private string? statusMessage; 17 | 18 | [CascadingParameter] 19 | private HttpContext HttpContext { get; set; } = default!; 20 | 21 | [SupplyParameterFromQuery] 22 | private string? UserId { get; set; } 23 | 24 | [SupplyParameterFromQuery] 25 | private string? Code { get; set; } 26 | 27 | protected override async Task OnInitializedAsync() 28 | { 29 | if (UserId is null || Code is null) 30 | { 31 | RedirectManager.RedirectTo(""); 32 | } 33 | 34 | var user = await UserManager.FindByIdAsync(UserId); 35 | if (user is null) 36 | { 37 | HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; 38 | statusMessage = $"Error loading user with ID {UserId}"; 39 | } 40 | else 41 | { 42 | var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); 43 | var result = await UserManager.ConfirmEmailAsync(user, code); 44 | statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email."; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/ConfirmEmailChange.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ConfirmEmailChange" 2 | 3 | @using System.Text 4 | @using Microsoft.AspNetCore.Identity 5 | @using Microsoft.AspNetCore.WebUtilities 6 | 7 | @inject UserManager UserManager 8 | @inject SignInManager SignInManager 9 | @inject IdentityRedirectManager RedirectManager 10 | 11 | Confirm email change 12 | 13 |

Confirm email change

14 | 15 | 16 | 17 | @code { 18 | private string? message; 19 | 20 | [CascadingParameter] 21 | private HttpContext HttpContext { get; set; } = default!; 22 | 23 | [SupplyParameterFromQuery] 24 | private string? UserId { get; set; } 25 | 26 | [SupplyParameterFromQuery] 27 | private string? Email { get; set; } 28 | 29 | [SupplyParameterFromQuery] 30 | private string? Code { get; set; } 31 | 32 | protected override async Task OnInitializedAsync() 33 | { 34 | if (UserId is null || Email is null || Code is null) 35 | { 36 | RedirectManager.RedirectToWithStatus( 37 | "Account/Login", "Error: Invalid email change confirmation link.", HttpContext); 38 | } 39 | 40 | var user = await UserManager.FindByIdAsync(UserId); 41 | if (user is null) 42 | { 43 | message = "Unable to find user with Id '{userId}'"; 44 | return; 45 | } 46 | 47 | var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); 48 | var result = await UserManager.ChangeEmailAsync(user, Email, code); 49 | if (!result.Succeeded) 50 | { 51 | message = "Error changing email."; 52 | return; 53 | } 54 | 55 | // In our UI email and user name are one and the same, so when we update the email 56 | // we need to update the user name. 57 | var setUserNameResult = await UserManager.SetUserNameAsync(user, Email); 58 | if (!setUserNameResult.Succeeded) 59 | { 60 | message = "Error changing user name."; 61 | return; 62 | } 63 | 64 | await SignInManager.RefreshSignInAsync(user); 65 | message = "Thank you for confirming your email change."; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/ForgotPassword.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ForgotPassword" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using System.Text 5 | @using System.Text.Encodings.Web 6 | @using Microsoft.AspNetCore.Identity 7 | @using Microsoft.AspNetCore.WebUtilities 8 | 9 | 10 | @inject UserManager UserManager 11 | @inject IEmailSender EmailSender 12 | @inject NavigationManager NavigationManager 13 | @inject IdentityRedirectManager RedirectManager 14 | 15 | Forgot your password? 16 | 17 |

Forgot your password?

18 |

Enter your email.

19 |
20 |
21 |
22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
31 | 32 |
33 |
34 |
35 | 36 | @code { 37 | [SupplyParameterFromForm] 38 | private InputModel Input { get; set; } = new(); 39 | 40 | private async Task OnValidSubmitAsync() 41 | { 42 | var user = await UserManager.FindByEmailAsync(Input.Email); 43 | if (user is null || !(await UserManager.IsEmailConfirmedAsync(user))) 44 | { 45 | // Don't reveal that the user does not exist or is not confirmed 46 | RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation"); 47 | } 48 | 49 | // For more information on how to enable account confirmation and password reset please 50 | // visit https://go.microsoft.com/fwlink/?LinkID=532713 51 | var code = await UserManager.GeneratePasswordResetTokenAsync(user); 52 | code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); 53 | var callbackUrl = NavigationManager.GetUriWithQueryParameters( 54 | NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri, 55 | new Dictionary { ["code"] = code }); 56 | 57 | await EmailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); 58 | 59 | RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation"); 60 | } 61 | 62 | private sealed class InputModel 63 | { 64 | [Required] 65 | [EmailAddress] 66 | public string Email { get; set; } = ""; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/ForgotPasswordConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ForgotPasswordConfirmation" 2 | 3 | Forgot password confirmation 4 | 5 |

Forgot password confirmation

6 |

7 | Please check your email to reset your password. 8 |

9 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/InvalidPasswordReset.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/InvalidPasswordReset" 2 | 3 | Invalid password reset 4 | 5 |

Invalid password reset

6 |

7 | The password reset link is invalid. 8 |

9 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/InvalidUser.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/InvalidUser" 2 | 3 | Invalid user 4 | 5 |

Invalid user

6 | 7 | 8 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/Lockout.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Lockout" 2 | 3 | Locked out 4 | 5 |
6 |

Locked out

7 | 8 |
9 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/Manage/Disable2fa.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/Disable2fa" 2 | 3 | @using Microsoft.AspNetCore.Identity 4 | 5 | 6 | @inject UserManager UserManager 7 | @inject IdentityUserAccessor UserAccessor 8 | @inject IdentityRedirectManager RedirectManager 9 | @inject ILogger Logger 10 | 11 | Disable two-factor authentication (2FA) 12 | 13 | 14 |

Disable two-factor authentication (2FA)

15 | 16 | 25 | 26 |
27 |
28 | 29 | 30 | 31 |
32 | 33 | @code { 34 | private PgSharpSiteUser user = default!; 35 | 36 | [CascadingParameter] 37 | private HttpContext HttpContext { get; set; } = default!; 38 | 39 | protected override async Task OnInitializedAsync() 40 | { 41 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 42 | 43 | if (HttpMethods.IsGet(HttpContext.Request.Method) && !await UserManager.GetTwoFactorEnabledAsync(user)) 44 | { 45 | throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled."); 46 | } 47 | } 48 | 49 | private async Task OnSubmitAsync() 50 | { 51 | var disable2faResult = await UserManager.SetTwoFactorEnabledAsync(user, false); 52 | if (!disable2faResult.Succeeded) 53 | { 54 | throw new InvalidOperationException("Unexpected error occurred disabling 2FA."); 55 | } 56 | 57 | var userId = await UserManager.GetUserIdAsync(user); 58 | Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId); 59 | RedirectManager.RedirectToWithStatus( 60 | "Account/Manage/TwoFactorAuthentication", 61 | "2fa has been disabled. You can reenable 2fa when you setup an authenticator app", 62 | HttpContext); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/Manage/GenerateRecoveryCodes.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/GenerateRecoveryCodes" 2 | 3 | @using Microsoft.AspNetCore.Identity 4 | 5 | 6 | @inject UserManager UserManager 7 | @inject IdentityUserAccessor UserAccessor 8 | @inject IdentityRedirectManager RedirectManager 9 | @inject ILogger Logger 10 | 11 | Generate two-factor authentication (2FA) recovery codes 12 | 13 | @if (recoveryCodes is not null) 14 | { 15 | 16 | } 17 | else 18 | { 19 |

Generate two-factor authentication (2FA) recovery codes

20 | 33 |
34 |
35 | 36 | 37 | 38 |
39 | } 40 | 41 | @code { 42 | private string? message; 43 | private PgSharpSiteUser user = default!; 44 | private IEnumerable? recoveryCodes; 45 | 46 | [CascadingParameter] 47 | private HttpContext HttpContext { get; set; } = default!; 48 | 49 | protected override async Task OnInitializedAsync() 50 | { 51 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 52 | 53 | var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(user); 54 | if (!isTwoFactorEnabled) 55 | { 56 | throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled."); 57 | } 58 | } 59 | 60 | private async Task OnSubmitAsync() 61 | { 62 | var userId = await UserManager.GetUserIdAsync(user); 63 | recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); 64 | message = "You have generated new recovery codes."; 65 | 66 | Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/Manage/PersonalData.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/PersonalData" 2 | 3 | @inject IdentityUserAccessor UserAccessor 4 | 5 | Personal Data 6 | 7 | 8 |

Personal Data

9 | 10 |
11 |
12 |

Your account contains personal data that you have given us. This page allows you to download or delete that data.

13 |

14 | Deleting this data will permanently remove your account, and this cannot be recovered. 15 |

16 |
17 | 18 | 19 | 20 |

21 | Delete 22 |

23 |
24 |
25 | 26 | @code { 27 | [CascadingParameter] 28 | private HttpContext HttpContext { get; set; } = default!; 29 | 30 | protected override async Task OnInitializedAsync() 31 | { 32 | _ = await UserAccessor.GetRequiredUserAsync(HttpContext); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/Manage/ResetAuthenticator.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/ResetAuthenticator" 2 | 3 | @using Microsoft.AspNetCore.Identity 4 | 5 | 6 | @inject UserManager UserManager 7 | @inject SignInManager SignInManager 8 | @inject IdentityUserAccessor UserAccessor 9 | @inject IdentityRedirectManager RedirectManager 10 | @inject ILogger Logger 11 | 12 | Reset authenticator key 13 | 14 | 15 |

Reset authenticator key

16 | 26 |
27 |
28 | 29 | 30 | 31 |
32 | 33 | @code { 34 | [CascadingParameter] 35 | private HttpContext HttpContext { get; set; } = default!; 36 | 37 | private async Task OnSubmitAsync() 38 | { 39 | var user = (PgSharpSiteUser)(await UserAccessor.GetRequiredUserAsync(HttpContext)); 40 | await UserManager.SetTwoFactorEnabledAsync(user, false); 41 | await UserManager.ResetAuthenticatorKeyAsync(user); 42 | var userId = await UserManager.GetUserIdAsync(user); 43 | Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId); 44 | 45 | await SignInManager.RefreshSignInAsync(user); 46 | 47 | RedirectManager.RedirectToWithStatus( 48 | "Account/Manage/EnableAuthenticator", 49 | "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.", 50 | HttpContext); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/Manage/_Imports.razor: -------------------------------------------------------------------------------- 1 | @layout ManageLayout 2 | @attribute [Microsoft.AspNetCore.Authorization.Authorize] 3 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/RegisterConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/RegisterConfirmation" 2 | 3 | @using System.Text 4 | @using Microsoft.AspNetCore.Identity 5 | @using Microsoft.AspNetCore.WebUtilities 6 | 7 | 8 | @inject UserManager UserManager 9 | @inject IEmailSender EmailSender 10 | @inject NavigationManager NavigationManager 11 | @inject IdentityRedirectManager RedirectManager 12 | 13 | Register confirmation 14 | 15 |

Register confirmation

16 | 17 | 18 | 19 | @if (emailConfirmationLink is not null) 20 | { 21 |

22 | This app does not currently have a real email sender registered, see these docs for how to configure a real email sender. 23 | Normally this would be emailed: Click here to confirm your account 24 |

25 | } 26 | else 27 | { 28 |

Please check your email to confirm your account.

29 | } 30 | 31 | @code { 32 | private string? emailConfirmationLink; 33 | private string? statusMessage; 34 | 35 | [CascadingParameter] 36 | private HttpContext HttpContext { get; set; } = default!; 37 | 38 | [SupplyParameterFromQuery] 39 | private string? Email { get; set; } 40 | 41 | [SupplyParameterFromQuery] 42 | private string? ReturnUrl { get; set; } 43 | 44 | protected override async Task OnInitializedAsync() 45 | { 46 | if (Email is null) 47 | { 48 | RedirectManager.RedirectTo(""); 49 | } 50 | 51 | var user = await UserManager.FindByEmailAsync(Email); 52 | if (user is null) 53 | { 54 | HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; 55 | statusMessage = "Error finding user for unspecified email"; 56 | } 57 | else if (EmailSender.GetType().Name == "IdentityNoOpEmailSender") 58 | { 59 | // Once you add a real email sender, you should remove this code that lets you confirm the account 60 | var userId = await UserManager.GetUserIdAsync(user); 61 | var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); 62 | code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); 63 | emailConfirmationLink = NavigationManager.GetUriWithQueryParameters( 64 | NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, 65 | new Dictionary { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl }); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/ResendEmailConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ResendEmailConfirmation" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using System.Text 5 | @using System.Text.Encodings.Web 6 | @using Microsoft.AspNetCore.Identity 7 | @using Microsoft.AspNetCore.WebUtilities 8 | 9 | 10 | @inject UserManager UserManager 11 | @inject IEmailSender EmailSender 12 | @inject NavigationManager NavigationManager 13 | @inject IdentityRedirectManager RedirectManager 14 | 15 | Resend email confirmation 16 | 17 |

Resend email confirmation

18 |

Enter your email.

19 |
20 | 21 |
22 |
23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
31 | 32 |
33 |
34 |
35 | 36 | @code { 37 | private string? message; 38 | 39 | [SupplyParameterFromForm] 40 | private InputModel Input { get; set; } = new(); 41 | 42 | private async Task OnValidSubmitAsync() 43 | { 44 | var user = await UserManager.FindByEmailAsync(Input.Email!); 45 | if (user is null) 46 | { 47 | message = "Verification email sent. Please check your email."; 48 | return; 49 | } 50 | 51 | var userId = await UserManager.GetUserIdAsync(user); 52 | var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); 53 | code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); 54 | var callbackUrl = NavigationManager.GetUriWithQueryParameters( 55 | NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, 56 | new Dictionary { ["userId"] = userId, ["code"] = code }); 57 | await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); 58 | 59 | message = "Verification email sent. Please check your email."; 60 | } 61 | 62 | private sealed class InputModel 63 | { 64 | [Required] 65 | [EmailAddress] 66 | public string Email { get; set; } = ""; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/ResetPasswordConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ResetPasswordConfirmation" 2 | Reset password confirmation 3 | 4 |

Reset password confirmation

5 |

6 | Your password has been reset. Please click here to log in. 7 |

8 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Pages/_Imports.razor: -------------------------------------------------------------------------------- 1 | @attribute [ExcludeFromInteractiveRouting] 2 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Shared/ExternalLoginPicker.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Authentication 2 | @using Microsoft.AspNetCore.Identity 3 | 4 | 5 | @inject SignInManager SignInManager 6 | @inject IdentityRedirectManager RedirectManager 7 | 8 | @if (externalLogins.Length == 0) 9 | { 10 |
11 |

12 | There are no external authentication services configured. See this article 13 | about setting up this ASP.NET application to support logging in via external services. 14 |

15 |
16 | } 17 | else 18 | { 19 |
20 |
21 | 22 | 23 |

24 | @foreach (var provider in externalLogins) 25 | { 26 | 27 | } 28 |

29 |
30 |
31 | } 32 | 33 | @code { 34 | private AuthenticationScheme[] externalLogins = []; 35 | 36 | [SupplyParameterFromQuery] 37 | private string? ReturnUrl { get; set; } 38 | 39 | protected override async Task OnInitializedAsync() 40 | { 41 | externalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToArray(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Shared/ManageLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 | 4 |

Manage your account

5 | 6 |
7 |

Change your account settings

8 |
9 |
10 |
11 | 12 |
13 |
14 | @Body 15 |
16 |
17 |
18 |
19 | 20 | @code { 21 | 22 | private Type? MainLayoutType { get; set; } 23 | 24 | protected override void OnInitialized() 25 | { 26 | 27 | MainLayoutType = Type.GetType("SharpSite.Web.Components.Layout.MainLayout, SharpSite.Web"); 28 | 29 | base.OnInitialized(); 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Shared/ManageNavMenu.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Identity 2 | 3 | 4 | @inject SignInManager SignInManager 5 | 6 | 29 | 30 | @code { 31 | private bool hasExternalLogins; 32 | 33 | protected override async Task OnInitializedAsync() 34 | { 35 | hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Shared/ShowRecoveryCodes.razor: -------------------------------------------------------------------------------- 1 |  2 |

Recovery codes

3 | 11 |
12 |
13 | @foreach (var recoveryCode in RecoveryCodes) 14 | { 15 |
16 | @recoveryCode 17 |
18 | } 19 |
20 |
21 | 22 | @code { 23 | [Parameter] 24 | public string[] RecoveryCodes { get; set; } = []; 25 | 26 | [Parameter] 27 | public string? StatusMessage { get; set; } 28 | } 29 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/Shared/StatusMessage.razor: -------------------------------------------------------------------------------- 1 | @if (!string.IsNullOrEmpty(DisplayMessage)) 2 | { 3 | var statusMessageClass = DisplayMessage.StartsWith("Error") ? "danger" : "success"; 4 | 7 | } 8 | 9 | @code { 10 | private string? messageFromCookie; 11 | 12 | [Parameter] 13 | public string? Message { get; set; } 14 | 15 | [CascadingParameter] 16 | private HttpContext HttpContext { get; set; } = default!; 17 | 18 | private string? DisplayMessage => Message ?? messageFromCookie; 19 | 20 | protected override void OnInitialized() 21 | { 22 | messageFromCookie = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName]; 23 | 24 | if (messageFromCookie is not null) 25 | { 26 | HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Account/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Components.Forms 2 | @using Microsoft.AspNetCore.Components.Routing 3 | @using Microsoft.AspNetCore.Components.Web 4 | @using SharpSite.Security.Postgres.Account.Shared 5 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/IdentityNoOpEmailSender.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity.UI.Services; 2 | 3 | namespace SharpSite.Security.Postgres; 4 | 5 | // Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation. 6 | internal sealed class IdentityNoOpEmailSender : IEmailSender 7 | { 8 | private readonly IEmailSender emailSender = new NoOpEmailSender(); 9 | 10 | public Task SendConfirmationLinkAsync(PgSharpSiteUser user, string email, string confirmationLink) => 11 | emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by clicking here."); 12 | 13 | public Task SendPasswordResetLinkAsync(PgSharpSiteUser user, string email, string resetLink) => 14 | emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); 15 | 16 | public Task SendPasswordResetCodeAsync(PgSharpSiteUser user, string email, string resetCode) => 17 | emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); 18 | } 19 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/IdentityRedirectManager.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace SharpSite.Security.Postgres; 4 | 5 | internal sealed class IdentityRedirectManager(NavigationManager navigationManager) 6 | { 7 | public const string StatusCookieName = "Identity.StatusMessage"; 8 | 9 | private static readonly CookieBuilder StatusCookieBuilder = new() 10 | { 11 | SameSite = SameSiteMode.Strict, 12 | HttpOnly = true, 13 | IsEssential = true, 14 | MaxAge = TimeSpan.FromSeconds(5), 15 | }; 16 | 17 | [DoesNotReturn] 18 | public void RedirectTo(string? uri) 19 | { 20 | uri ??= ""; 21 | 22 | // Prevent open redirects. 23 | if (!Uri.IsWellFormedUriString(uri, UriKind.Relative)) 24 | { 25 | uri = navigationManager.ToBaseRelativePath(uri); 26 | } 27 | 28 | // During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect. 29 | // So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown. 30 | navigationManager.NavigateTo(uri); 31 | throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} can only be used during static rendering."); 32 | } 33 | 34 | [DoesNotReturn] 35 | public void RedirectTo(string uri, Dictionary queryParameters) 36 | { 37 | var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path); 38 | var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters); 39 | RedirectTo(newUri); 40 | } 41 | 42 | [DoesNotReturn] 43 | public void RedirectToWithStatus(string uri, string message, HttpContext context) 44 | { 45 | context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context)); 46 | RedirectTo(uri); 47 | } 48 | 49 | private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path); 50 | 51 | [DoesNotReturn] 52 | public void RedirectToCurrentPage() => RedirectTo(CurrentPath); 53 | 54 | [DoesNotReturn] 55 | public void RedirectToCurrentPageWithStatus(string message, HttpContext context) 56 | => RedirectToWithStatus(CurrentPath, message, context); 57 | } 58 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/IdentityRevalidatingAuthenticationStateProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components.Authorization; 2 | using Microsoft.AspNetCore.Components.Server; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Options; 5 | using System.Security.Claims; 6 | 7 | namespace SharpSite.Security.Postgres; 8 | 9 | // This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user 10 | // every 30 minutes an interactive circuit is connected. 11 | internal sealed class IdentityRevalidatingAuthenticationStateProvider( 12 | ILoggerFactory loggerFactory, 13 | IServiceScopeFactory scopeFactory, 14 | IOptions options) 15 | : RevalidatingServerAuthenticationStateProvider(loggerFactory) 16 | { 17 | protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); 18 | 19 | protected override async Task ValidateAuthenticationStateAsync( 20 | AuthenticationState authenticationState, CancellationToken cancellationToken) 21 | { 22 | // Get the user manager from a new scope to ensure it fetches fresh data 23 | await using var scope = scopeFactory.CreateAsyncScope(); 24 | var userManager = scope.ServiceProvider.GetRequiredService>(); 25 | return await ValidateSecurityStampAsync(userManager, authenticationState.User); 26 | } 27 | 28 | private async Task ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal) 29 | { 30 | var user = await userManager.GetUserAsync(principal); 31 | if (user is null) 32 | { 33 | return false; 34 | } 35 | else if (!userManager.SupportsUserSecurityStamp) 36 | { 37 | return true; 38 | } 39 | else 40 | { 41 | var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType); 42 | var userStamp = await userManager.GetSecurityStampAsync(user); 43 | return principalStamp == userStamp; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/IdentityUserAccessor.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Security.Postgres; 2 | 3 | internal sealed class IdentityUserAccessor(UserManager userManager, IdentityRedirectManager redirectManager) 4 | { 5 | public async Task GetRequiredUserAsync(HttpContext context) 6 | { 7 | var user = await userManager.GetUserAsync(context.User); 8 | 9 | if (user is null) 10 | { 11 | redirectManager.RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context); 12 | } 13 | 14 | return user; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Migrations/20241206163417_User DisplayName.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace SharpSite.Security.Postgres.Migrations 6 | { 7 | /// 8 | public partial class UserDisplayName : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.AddColumn( 14 | name: "DisplayName", 15 | table: "AspNetUsers", 16 | type: "character varying(50)", 17 | maxLength: 50, 18 | nullable: false, 19 | defaultValue: ""); 20 | } 21 | 22 | /// 23 | protected override void Down(MigrationBuilder migrationBuilder) 24 | { 25 | migrationBuilder.DropColumn( 26 | name: "DisplayName", 27 | table: "AspNetUsers"); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/PgSharpSiteUser.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore; 3 | using SharpSite.Abstractions; 4 | using System.ComponentModel.DataAnnotations; 5 | 6 | namespace SharpSite.Security.Postgres; 7 | 8 | public class PgSharpSiteUser : IdentityUser 9 | { 10 | 11 | [PersonalData, Required, MaxLength(50)] 12 | public required string DisplayName { get; set; } 13 | 14 | public static explicit operator SharpSiteUser(PgSharpSiteUser user) => 15 | new(user.Id, user.UserName, user.Email) 16 | { 17 | DisplayName = user.DisplayName 18 | }; 19 | 20 | public static explicit operator PgSharpSiteUser(SharpSiteUser user) => 21 | new() 22 | { 23 | Id = user.Id, 24 | DisplayName = user.DisplayName, 25 | UserName = user.UserName, 26 | Email = user.Email 27 | }; 28 | 29 | } 30 | 31 | public class PgSecurityContext : IdentityDbContext 32 | { 33 | public PgSecurityContext(DbContextOptions options) 34 | : base(options) 35 | { 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "SharpSite.Security.Postgres": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | }, 9 | "applicationUrl": "https://localhost:58101;http://localhost:58102" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/SharpSite.Security.Postgres.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/SharpSite.Security.Postgres/UserRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using SharpSite.Abstractions; 4 | using System.Security.Claims; 5 | 6 | namespace SharpSite.Security.Postgres; 7 | 8 | public class UserRepository(IServiceProvider services) : IUserRepository 9 | { 10 | 11 | private SharpSiteUser CurrentUser = null!; 12 | 13 | public async Task GetUserAsync(ClaimsPrincipal user) 14 | { 15 | 16 | if (CurrentUser is null) 17 | { 18 | 19 | using var scope = services.CreateScope(); 20 | var userManager = scope.ServiceProvider.GetRequiredService>(); 21 | 22 | var pgUser = await userManager.GetUserAsync(user); 23 | if (pgUser is null) return null!; 24 | 25 | CurrentUser = (SharpSiteUser)pgUser; 26 | } 27 | 28 | return CurrentUser; 29 | 30 | } 31 | 32 | public async Task> GetAllUsersAsync() 33 | { 34 | using var scope = services.CreateScope(); 35 | var userManager = scope.ServiceProvider.GetRequiredService(); 36 | var pgUsers = await userManager.Users 37 | .GroupJoin(userManager.UserRoles, u => u.Id, ur => ur.UserId, (u, urs) => new { u, urs }) 38 | .SelectMany( 39 | x => x.urs.DefaultIfEmpty(), 40 | (x, ur) => new { x.u, ur } 41 | ) 42 | .GroupJoin(userManager.Roles, x => x.ur!.RoleId, r => r.Id, (x, rs) => new { x.u, x.ur, rs }) 43 | .SelectMany( 44 | x => x.rs.DefaultIfEmpty(), 45 | (x, r) => new SharpSiteUser(x.u.Id, x.u.UserName, x.u.Email) 46 | { 47 | DisplayName = x.u.DisplayName, 48 | PhoneNumber = x.u.PhoneNumber, 49 | Role = r != null ? r.Name : "No Role Assigned" 50 | } 51 | ).ToListAsync(); 52 | 53 | return pgUsers; 54 | } 55 | 56 | public async Task UpdateRoleForUserAsync(SharpSiteUser user) 57 | { 58 | 59 | if (user is null) return; 60 | 61 | using var scope = services.CreateScope(); 62 | var userManager = scope.ServiceProvider.GetRequiredService>(); 63 | 64 | var existingUser = userManager.Users.FirstOrDefault(u => u.Id == user.Id); 65 | if (existingUser is null) return; 66 | 67 | var existingRole = (await userManager.GetRolesAsync(existingUser)).FirstOrDefault(); 68 | if (existingRole is not null) await userManager.RemoveFromRoleAsync(existingUser, existingRole); 69 | 70 | if (user.Role is not null) 71 | await userManager.AddToRoleAsync(existingUser, user.Role); 72 | 73 | 74 | } 75 | } -------------------------------------------------------------------------------- /src/SharpSite.ServiceDefaults/SharpSite.ServiceDefaults.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/AdminLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | @layout SharpSite.Web.Components.Layout.MainLayout 3 | 4 |

@Localizer[SharedResource.sharpsite_admin_layout_h1]

5 | 6 |
7 |

@Localizer[SharedResource.sharpsite_admin_layout_h2]

8 |
9 |
10 | 13 |
14 | @Body 15 |
16 |
17 |
-------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/ConfigurePageNotFound.razor: -------------------------------------------------------------------------------- 1 | @inject ApplicationState AppState 2 | @rendermode InteractiveServer 3 | 4 |

@SharedResource.sharpsite_CustomizePageNotFoundHeader

5 | 6 |

@SharedResource.sharpsite_CustomizePageNotFoundDescription

7 | 8 | 9 | 10 | 11 | 12 | 13 | @code { 14 | 15 | private string PageNotFoundContent { get; set; } = string.Empty; 16 | 17 | protected override void OnParametersSet() 18 | { 19 | PageNotFoundContent = AppState.PageNotFoundContent; 20 | base.OnParametersSet(); 21 | } 22 | 23 | private async Task SavePageNotFoundContent(MouseEventArgs e) 24 | { 25 | 26 | // copy the PageNotFoundContent to the AppState and save AppState 27 | AppState.PageNotFoundContent = PageNotFoundContent; 28 | await AppState.Save(); 29 | 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/EditPage.razor: -------------------------------------------------------------------------------- 1 | @page "/admin/EditPage/{Id:int?}" 2 | @attribute [Authorize(Roles = Constants.Roles.AdminUsers)] 3 | @inject IPageRepository PageRepository 4 | @inject NavigationManager NavManager 5 | @rendermode InteractiveServer 6 | 7 | @ThisPageTitle 8 |

@ThisPageTitle

9 | 10 | @if (Page != null) 11 | { 12 |
13 | 14 |
15 | 16 |
17 |
18 | 19 |
20 | 21 |
22 | 23 | 24 | } 25 | 26 | @code { 27 | [Parameter] 28 | public int Id { get; set; } 29 | 30 | private Page? Page { get; set; } 31 | 32 | private string ThisPageTitle = string.Empty; 33 | 34 | protected override async Task OnInitializedAsync() 35 | { 36 | if (Id != 0) 37 | { 38 | Page = await PageRepository.GetPage(Id); 39 | ThisPageTitle = SharedResource.sharpsite_editpage; 40 | } 41 | else 42 | { 43 | Page = new Page() 44 | { 45 | Title = "", 46 | Slug = "", 47 | Content = "", 48 | LastUpdate = DateTimeOffset.Now 49 | }; 50 | ThisPageTitle = SharedResource.sharpsite_newpage; 51 | } 52 | } 53 | 54 | private async Task SavePage() 55 | { 56 | 57 | if (Id == 0) 58 | { 59 | // format and set the slug based on the title 60 | Page!.Slug = Page!.Title.ToLower().Replace(" ", "-"); 61 | Page!.LastUpdate = DateTimeOffset.Now; 62 | await PageRepository.AddPage(Page!); 63 | } 64 | else 65 | { 66 | Page!.LastUpdate = DateTimeOffset.Now; 67 | await PageRepository.UpdatePage(Page!); 68 | } 69 | NavManager.NavigateTo("/admin/Pages"); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/EnumField.razor: -------------------------------------------------------------------------------- 1 | @typeparam TEntity 2 | 3 | @using System.Reflection 4 | @using System.ComponentModel.DataAnnotations 5 | @using Microsoft.AspNetCore.Components 6 | 7 | @if (Property != null) 8 | { 9 | 17 | } 18 | 19 | @code { 20 | [Parameter] 21 | public TEntity? Entity { get; set; } 22 | [Parameter] 23 | public PropertyInfo? Property { get; set; } 24 | 25 | private void HandleOnChange(ChangeEventArgs e) 26 | { 27 | var valueStr = e.Value?.ToString(); 28 | 29 | if (Property is null) 30 | { 31 | return; 32 | } 33 | 34 | if (string.IsNullOrWhiteSpace(valueStr)) 35 | { 36 | return; 37 | } 38 | 39 | if (Enum.TryParse(Property.PropertyType, valueStr, out var parsed)) 40 | { 41 | Property.SetValue(Entity, parsed); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/admin" 2 | @attribute [Authorize(Roles = Constants.Roles.AllUsers)] 3 | 4 | @Localizer[SharedResource.sharpsite_admin] 5 | 6 |

@Localizer[SharedResource.sharpsite_admin_headertext]

7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/LanguageSelect.razor: -------------------------------------------------------------------------------- 1 | 
2 | 3 | 9 | @Localizer[SharedResource.sharpsite_langauge_help_text] 10 |
11 | 12 | @code { 13 | [Parameter] 14 | public required string Language { get; set; } 15 | 16 | [Parameter] 17 | public EventCallback LanguageChanged { get; set; } 18 | 19 | private async Task SetAsync(string value) => await LanguageChanged.InvokeAsync(value); 20 | 21 | private IEnumerable _Cultures { get; set; } = Enumerable.Empty(); 22 | 23 | protected override void OnInitialized() 24 | { 25 | _Cultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures).Skip(1); // Skips Invariant Language. 26 | base.OnInitialized(); 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/ManageNavMenu.razor: -------------------------------------------------------------------------------- 1 | @inject ApplicationState AppState 2 | 54 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/PageList.razor: -------------------------------------------------------------------------------- 1 | @page "/admin/Pages" 2 | @attribute [Authorize(Roles = Constants.Roles.AdminUsers)] 3 | @inject IPageRepository PageRepository 4 | @inject NavigationManager NavManager 5 | @rendermode InteractiveServer 6 | 7 | @Localizer[SharedResource.sharpsite_pageadmin] 8 |

@Localizer[SharedResource.sharpsite_pageadmin]

9 | 10 | @* show a list of pages that are currently in use. Each page record should be a link to a 'EditPage' page*@ 11 | @foreach (var p in Pages) 12 | { 13 |
14 |
15 |
@p.Title
16 |

@p.Content

17 | @Localizer[SharedResource.sharpsite_edit] 18 | 19 | 20 |
21 |
22 | } 23 | 24 | @* add a button to create a new page*@ 25 | @Localizer[SharedResource.sharpsite_newpage] 26 | 27 | @code { 28 | IEnumerable Pages { get; set; } = Enumerable.Empty(); 29 | 30 | protected override async Task OnInitializedAsync() 31 | { 32 | Pages = await PageRepository.GetPages(); 33 | } 34 | 35 | private async Task DeletePage(int id) 36 | { 37 | await PageRepository.DeletePage(id); 38 | NavManager.NavigateTo("/admin/Pages",true); 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/PluginCard.razor: -------------------------------------------------------------------------------- 1 | 
2 |
3 |

@Plugin.DisplayName

4 | 5 | @Plugin.Version 6 |
7 | @Localizer[SharedResource.sharpsite_plugin_icon] 11 |
12 |

@Plugin.Description

13 |
14 | 18 |
19 | 20 | @code { 21 | 22 | private const string DefaultPluginIcon = "plugin-icon.svg"; 23 | 24 | [Parameter, EditorRequired] public required PluginManifest Plugin { get; set; } 25 | } 26 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/PluginCard.razor.css: -------------------------------------------------------------------------------- 1 | .plugin-card { 2 | width: 14rem; 3 | } 4 | 5 | .plugin-icon { 6 | aspect-ratio: 1; 7 | object-fit: cover; 8 | } 9 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/PluginList.razor: -------------------------------------------------------------------------------- 1 | @page "/admin/plugins" 2 | @attribute [Authorize(Roles = Constants.Roles.Admin)] 3 | @inject ApplicationState AppState 4 | 5 | @Localizer[SharedResource.sharpsite_installedplugins] 6 | 7 |

8 | @Localizer[SharedResource.sharpsite_installedplugins]

9 | 10 | @* Add a button that links to the add plugin page With the text add new plugin *@ 11 | 14 | 15 | 16 | @if (AppState.Plugins.Count == 0) 17 | { 18 |

19 | @Localizer[SharedResource.sharpsite_nopluginsinstalled]

20 | } 21 | else 22 | { 23 |
24 | @foreach (var plugin in AppState.Plugins) 25 | { 26 | 27 | } 28 |
29 | } 30 | 31 | @code { 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/PostList.razor: -------------------------------------------------------------------------------- 1 | @attribute [Route(RouteValues.AdminPostList)] 2 | @attribute [Authorize()] 3 | @using Microsoft.AspNetCore.Components.QuickGrid 4 | @inject IPostRepository PostService 5 | 6 | 7 | @Localizer[SharedResource.sharpsite_postsadmin] 8 | 9 |

@Localizer[SharedResource.sharpsite_postsadmin]

10 | 11 | @if (Posts is null) 12 | { 13 |

@Localizer[SharedResource.sharpsite_loading]

14 | } 15 | else 16 | { 17 | 18 | @** add a link to create a new post *@ 19 | @Localizer[SharedResource.sharpsite_newpost] 20 | 21 | 22 | @** use a quickgrid to format the list of posts with each posts title being a link to the editpost page for that post *@ 23 | 24 | 25 | @context.Title 26 | 27 | 30 | 31 | } 32 | 33 | @code { 34 | private IEnumerable? Posts { get; set; } 35 | 36 | private IQueryable? GridPosts => Posts?.AsQueryable(); 37 | 38 | protected override async Task OnInitializedAsync() 39 | { 40 | Posts = await PostService.GetPosts(); 41 | } 42 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/SiteAppearance.razor: -------------------------------------------------------------------------------- 1 | @page "/admin/site" 2 | @attribute [Authorize(Roles = Constants.Roles.Admin)] 3 | 4 | @Localizer[SharedResource.sharpsite_site_appearance_admin] 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/ThemeSelect.razor: -------------------------------------------------------------------------------- 1 | @inject ApplicationState AppState 2 | @inject NavigationManager NavigationManager 3 | @rendermode @(new InteractiveServerRenderMode(true)) 4 | 5 | @* generate a theme selector combobox based on the list of availablethemeplugins *@ 6 |

@Localizer[SharedResource.sharpsite_theme_selector]

7 | 15 | 16 | 17 | 18 | @code { 19 | 20 | PluginManifest[] AvailableThemePlugins = Array.Empty(); 21 | 22 | string SelectedTheme = string.Empty; 23 | 24 | protected override void OnInitialized() 25 | { 26 | 27 | SelectedTheme = AppState.CurrentTheme is not null ? AppState.CurrentTheme.IdVersion : string.Empty; 28 | 29 | AvailableThemePlugins = AppState.Plugins.Values 30 | .Where(p => p.Features.Contains(PluginFeatures.Theme)) 31 | .ToArray(); 32 | 33 | base.OnInitialized(); 34 | } 35 | private async Task ChangeTheme() 36 | { 37 | 38 | if (string.IsNullOrWhiteSpace(SelectedTheme)) 39 | { 40 | AppState.CurrentTheme = null; 41 | } 42 | else 43 | { 44 | AppState.SetTheme(AvailableThemePlugins.First(p => p.IdVersionToString() == SelectedTheme)); 45 | } 46 | await AppState.Save(); 47 | NavigationManager.NavigateTo("/admin", true); 48 | 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/UserList.razor: -------------------------------------------------------------------------------- 1 | @page "/admin/users" 2 | @attribute [Authorize(Roles = Constants.Roles.AdminUsers)] 3 | @rendermode InteractiveServer 4 | @using Microsoft.AspNetCore.Components.QuickGrid 5 | @inject IUserRepository UserRepository 6 | 7 | @Localizer[SharedResource.sharpsite_useradmin] 8 | 9 |

@Localizer[SharedResource.sharpsite_userlist]

10 | 11 | 12 | 13 | 14 | 15 | @if (context.UserName != editUserId) 16 | { 17 | 18 | } 19 | else 20 | { 21 | 22 | 23 | @foreach (var role in Constants.Roles.AllRoles) 24 | { 25 | 26 | } 27 | 28 | 29 | 30 | } 31 | 32 | 33 | 34 | 35 | @if (Users?.Count() > 10) 36 | { 37 | 38 | } 39 | 40 | @code { 41 | 42 | private IEnumerable? Users { get; set; } 43 | IQueryable? GridUsers => Users?.AsQueryable(); 44 | 45 | PaginationState pagination = new PaginationState { ItemsPerPage = 10 }; 46 | 47 | string editUserId = string.Empty; 48 | 49 | protected override async Task OnInitializedAsync() 50 | { 51 | 52 | Users = await UserRepository.GetAllUsersAsync(); 53 | 54 | await base.OnInitializedAsync(); 55 | 56 | } 57 | private async Task SaveRoleForUser() 58 | { 59 | var editUser = Users!.First(u => u.UserName == editUserId); 60 | await UserRepository.UpdateRoleForUserAsync(editUser); 61 | editUserId = string.Empty; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Admin/_Imports.razor: -------------------------------------------------------------------------------- 1 | @layout AdminLayout -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/App.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Localization 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | @code { 34 | [CascadingParameter] 35 | public HttpContext? HttpContext { get; set; } 36 | 37 | protected override void OnInitialized() 38 | { 39 | HttpContext?.Response.Cookies.Append( 40 | CookieRequestCultureProvider.DefaultCookieName, 41 | CookieRequestCultureProvider.MakeCookieValue( 42 | new RequestCulture( 43 | CultureInfo.CurrentCulture, 44 | CultureInfo.CurrentUICulture))); 45 | } 46 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/LanguagePicker.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.Extensions.Options 2 | 3 | @inject IOptions LocalizationOptions 4 | @inject NavigationManager Navigation 5 | @rendermode @(new InteractiveServerRenderMode(true)) 6 | 7 | @if (LocalizationOptions.Value.SupportedCultures is not null && LocalizationOptions.Value.SupportedCultures.Count > 1) 8 | { 9 |
10 | 18 |
19 | } 20 | 21 | @code 22 | { 23 | private string _selectedCulture = "en"; 24 | 25 | private string SelectedCulture 26 | { 27 | get => _selectedCulture; 28 | set 29 | { 30 | _selectedCulture = value; 31 | Navigation.NavigateTo(SetCulture(_selectedCulture)); 32 | } 33 | } 34 | 35 | private string SetCulture(string culture) 36 | { 37 | const string cultureParamName = "culture"; 38 | var url = Navigation.GetUriWithQueryParameter(cultureParamName, culture); 39 | return url; 40 | } 41 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Layout/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | @inject IStringLocalizer Localizer 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 13 | 14 |
15 | 16 |
17 | @Body 18 |
19 |
20 |
21 | 22 |
23 | @Localizer[SharedResource.sharpsite_unhandled_exception] 24 | Reload 25 | 🗙 26 |
27 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Layout/MainLayout.razor.css: -------------------------------------------------------------------------------- 1 | .page { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | main { 8 | flex: 1; 9 | } 10 | 11 | .sidebar { 12 | background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); 13 | } 14 | 15 | .top-row { 16 | background-color: #f7f7f7; 17 | border-bottom: 1px solid #d6d5d5; 18 | justify-content: flex-end; 19 | height: 3.5rem; 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .top-row ::deep a, .top-row ::deep .btn-link { 25 | white-space: nowrap; 26 | margin-left: 1.5rem; 27 | text-decoration: none; 28 | } 29 | 30 | .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { 31 | text-decoration: underline; 32 | } 33 | 34 | .top-row ::deep a:first-child { 35 | overflow: hidden; 36 | text-overflow: ellipsis; 37 | } 38 | 39 | @media (max-width: 640.98px) { 40 | .top-row { 41 | justify-content: space-between; 42 | } 43 | 44 | .top-row ::deep a, .top-row ::deep .btn-link { 45 | margin-left: 0; 46 | } 47 | } 48 | 49 | @media (min-width: 641px) { 50 | .page { 51 | flex-direction: row; 52 | } 53 | 54 | .sidebar { 55 | width: 250px; 56 | height: 100vh; 57 | position: sticky; 58 | top: 0; 59 | } 60 | 61 | .top-row { 62 | position: sticky; 63 | top: 0; 64 | z-index: 1; 65 | } 66 | 67 | .top-row.auth ::deep a:first-child { 68 | flex: 1; 69 | text-align: right; 70 | width: 0; 71 | } 72 | 73 | .top-row, article { 74 | padding-left: 2rem !important; 75 | padding-right: 1.5rem !important; 76 | } 77 | } 78 | 79 | #blazor-error-ui { 80 | background: lightyellow; 81 | bottom: 0; 82 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 83 | display: none; 84 | left: 0; 85 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 86 | position: fixed; 87 | width: 100%; 88 | z-index: 1000; 89 | } 90 | 91 | #blazor-error-ui .dismiss { 92 | cursor: pointer; 93 | position: absolute; 94 | right: 0.75rem; 95 | top: 0.5rem; 96 | } 97 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Layout/Theme.razor: -------------------------------------------------------------------------------- 1 | @inject ApplicationState AppState 2 | @using SharpSite.Abstractions.Theme 3 | 4 | @foreach (var item in (Stylesheets)) 5 | { 6 | var theUrl = item.StartsWith("http") ? item : FormatThemeStylesheetLocation(item); 7 | 8 | } 9 | 10 | @code { 11 | IHasStylesheets? ThemeComponent; 12 | 13 | string[] Stylesheets = []; 14 | 15 | protected override void OnInitialized() 16 | { 17 | 18 | ThemeComponent = AppState.ThemeType is not null && AppState.ThemeType != null 19 | ? Activator.CreateInstance(AppState.ThemeType) as IHasStylesheets 20 | : null; 21 | 22 | Stylesheets = ThemeComponent?.Stylesheets ?? []; 23 | 24 | base.OnInitialized(); 25 | } 26 | 27 | string FormatThemeStylesheetLocation(string stylesheet) 28 | { 29 | return $"/Plugins/{AppState.CurrentTheme!.IdVersion}/{stylesheet}"; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/PageNotFound.razor: -------------------------------------------------------------------------------- 1 | @using System.Net 2 | @using Markdig 3 | @inject ILogger Logger 4 | @inject ApplicationState AppState 5 | 6 | 7 | @Localizer[SharedResource.sharpsite_pagenotfound] 8 | 9 |

@Localizer[SharedResource.sharpsite_pagenotfound]

10 | 11 | @if (string.IsNullOrEmpty(AppState.PageNotFoundContent)) 12 | { 13 | @if (!string.IsNullOrEmpty(PageRequested)) 14 | { 15 |

@string.Format(Localizer[SharedResource.sharpsite_pagenotfound_withrequestedpage], PageRequested)

16 | } 17 | } 18 | else 19 | { 20 | @((MarkupString)Markdown.ToHtml(AppState.PageNotFoundContent)) 21 | } 22 |

@Localizer[SharedResource.sharpsite_backtohome]

23 | 24 | @code 25 | { 26 | 27 | [CascadingParameter] 28 | public HttpContext? HttpContext { get; set; } 29 | 30 | [Parameter] 31 | public string? PageRequested { get; set; } 32 | 33 | protected override void OnInitialized() 34 | { 35 | if (HttpContext is not null) 36 | HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; 37 | base.OnInitialized(); 38 | } 39 | 40 | protected override void OnParametersSet() 41 | { 42 | Logger.LogError("Page {0} requested and not available", PageRequested); 43 | base.OnParametersSet(); 44 | } 45 | 46 | 47 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Pages/About.razor: -------------------------------------------------------------------------------- 1 | @page "/aboutSharpSite" 2 | @inject IStringLocalizer Localizer 3 | 4 | @Localizer[SharedResource.sharpsite_about] 5 | 6 |

@Localizer[SharedResource.sharpsite_about]

7 | 8 |

@Localizer[SharedResource.sharpsite_about_description]

9 | 10 |

@Localizer[SharedResource.sharpsite_about_packages]

11 | 12 | 16 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Pages/DisplayPage.razor: -------------------------------------------------------------------------------- 1 | @page "/{slug:minlength(5):required:nonfile}" 2 | @using Markdig 3 | @inject IPageRepository PageRepository 4 | @inject NavigationManager NavigationManager 5 | 6 | @if (Page is not null) 7 | { 8 | @Page.Title 9 |
10 |

@Page.Title

11 | @((MarkupString)Markdown.ToHtml(Page.Content)) 12 |
13 | } 14 | else 15 | { 16 | 17 | } 18 | 19 | @code { 20 | [Parameter] public string Slug { get; set; } = string.Empty; 21 | private Page? Page { get; set; } 22 | 23 | protected override async Task OnInitializedAsync() 24 | { 25 | Page = await PageRepository.GetPage(Slug); 26 | } 27 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Pages/DisplayPost.razor: -------------------------------------------------------------------------------- 1 | @page "/{urldate:int}/{slug}" 2 | @using Markdig 3 | @inject IPostRepository PostService 4 | @inject IStringLocalizer Localizer 5 | @inject NavigationManager NavigationManager 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | @if (Post is not null) 17 | { 18 | SharpSite | @Post.Title 19 |
20 |

@Post.Title

21 |
@Post.PublishedDate.LocalDateTime
22 |

@((MarkupString)Markdown.ToHtml(Post.Content))

23 |
24 | 25 |
26 | @Localizer[SharedResource.sharpsite_backtohome] 27 | 28 | } 29 | else 30 | { 31 | @Localizer[SharedResource.sharpsite_pagenotfound] 32 | 33 | } 34 | 35 | 36 | @code { 37 | [Parameter] public int UrlDate { get; set; } 38 | [Parameter] public required string Slug { get; set; } 39 | private Post? Post { get; set; } 40 | 41 | protected override async Task OnInitializedAsync() 42 | { 43 | Post = await PostService.GetPost(UrlDate.ToString(), Slug); 44 | } 45 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Pages/Error.razor: -------------------------------------------------------------------------------- 1 | @page "/Error" 2 | @using System.Diagnostics 3 | @using System.Net 4 | @using Microsoft.AspNetCore.Diagnostics 5 | 6 | Error 7 | 8 | @if (StatusCode == "404") 9 | { 10 | 11 | } else 12 | { 13 |

An error occurred while processing your request.

14 |

Please contact the site administrator.

15 | 16 | 17 | @if (ShowRequestId) 18 | { 19 |

20 | Request ID: @requestId 21 |

22 | } 23 | 24 |

Development Mode

25 |

26 | Swapping to Development environment will display more detailed information about the error that occurred. 27 |

28 |

29 | The Development environment shouldn't be enabled for deployed applications. 30 | It can result in displaying sensitive information from exceptions to end users. 31 | For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development 32 | and restarting the app. 33 |

34 | 35 | 36 | } 37 | 38 | @code { 39 | [CascadingParameter] 40 | public HttpContext? HttpContext { get; set; } 41 | 42 | [Parameter, SupplyParameterFromQuery] 43 | public string? StatusCode { get; set; } 44 | 45 | private string? requestId; 46 | private bool ShowRequestId => !string.IsNullOrEmpty(requestId); 47 | 48 | public string OriginalUrlRequested { get; set; } = string.Empty; 49 | 50 | protected override void OnInitialized() 51 | { 52 | requestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; 53 | 54 | if (HttpContext is not null) { 55 | var feature = HttpContext.Features.Get(); 56 | OriginalUrlRequested = feature?.OriginalPath ?? string.Empty; 57 | } 58 | 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Pages/Home.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @inject IPostRepository PostService 3 | 4 | SharpSite 5 | 6 | @* NOTE: Every page should have a h1-element, can be visually hidden if it suits the design. *@ 7 |

@Localizer[SharedResource.sharpsite_home_page]

8 | 9 | @if (Posts is not null) 10 | { 11 | @foreach (var post in Posts) 12 | { 13 | 14 | } 15 | } 16 | 17 | @code { 18 | private IEnumerable? Posts { get; set; } 19 | 20 | protected override async Task OnInitializedAsync() 21 | { 22 | Posts = (await PostService.GetPosts()).OrderByDescending(p => p.PublishedDate); 23 | } 24 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/PostView.razor: -------------------------------------------------------------------------------- 1 |

@item.Title

2 |

@item.PublishedDate.LocalDateTime.ToShortDateString()

3 | 4 |

@item.Description

5 | 6 | @code { 7 | [Parameter, EditorRequired] 8 | public required Post item { get; set; } 9 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/RedirectToLogin.razor: -------------------------------------------------------------------------------- 1 | @inject NavigationManager NavigationManager 2 | 3 | @code { 4 | protected override void OnInitialized() 5 | { 6 | NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/Routes.razor: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/SeoHeaderTags.razor: -------------------------------------------------------------------------------- 1 | @inject NavigationManager NavigationManager 2 | 3 | 4 | @* add typical og and social media meta tags for discovery *@ 5 | 6 | @* *@ 7 | 8 | 9 | 10 | @* TODO: This should be replaced with a name the Site Admin gives to this site *@ 11 | 12 | 13 | @* 14 | 15 | site is the Twitter handle of the site 16 | creator is the Twitter handle of the author 17 | 18 | 19 | 20 | 21 | *@ 22 | 23 | 24 | @if (!string.IsNullOrEmpty(Description)) 25 | { 26 | 27 | 28 | } 29 | @* *@ 30 | 31 | 32 | 33 | @if (PublishedDate.HasValue) 34 | { 35 | 36 | } 37 | @* 38 | 39 | 40 | 41 | 42 | 43 | 44 | *@ 45 | 46 | @code { 47 | 48 | [Parameter, EditorRequired] 49 | public required string Title { get; set; } 50 | 51 | [Parameter] 52 | public string? Description { get; set; } 53 | 54 | [Parameter] 55 | public DateTimeOffset? PublishedDate { get; set; } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Components/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Authorization 4 | @using Microsoft.AspNetCore.Components.Authorization 5 | @using Microsoft.AspNetCore.Components.Forms 6 | @using Microsoft.AspNetCore.Components.Routing 7 | @using Microsoft.AspNetCore.Components.Sections 8 | @using Microsoft.AspNetCore.Components.Web 9 | @using static Microsoft.AspNetCore.Components.Web.RenderMode 10 | @using Microsoft.AspNetCore.Components.Web.Virtualization 11 | @using Microsoft.AspNetCore.OutputCaching 12 | @using Microsoft.JSInterop 13 | @using SharpSite.Web 14 | @using SharpSite.Web.Components 15 | @using SharpSite.Abstractions 16 | @using System.Globalization 17 | @using Microsoft.Extensions.Localization 18 | @using SharpSite.Plugins 19 | @inject IStringLocalizer Localizer 20 | @using SharedResource = Locales.SharedResource 21 | -------------------------------------------------------------------------------- /src/SharpSite.Web/IdentityNoOpEmailSender.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using Microsoft.AspNetCore.Identity.UI.Services; 3 | using SharpSite.Abstractions; 4 | 5 | namespace SharpSite.Web 6 | { 7 | // Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation. 8 | internal sealed class IdentityNoOpEmailSender : IEmailSender 9 | { 10 | private readonly IEmailSender emailSender = new NoOpEmailSender(); 11 | 12 | public Task SendConfirmationLinkAsync(SharpSiteUser user, string email, string confirmationLink) => 13 | emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by clicking here."); 14 | 15 | public Task SendPasswordResetLinkAsync(SharpSiteUser user, string email, string resetLink) => 16 | emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); 17 | 18 | public Task SendPasswordResetCodeAsync(SharpSiteUser user, string email, string resetCode) => 19 | emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Locales/Configuration.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Web.Locales; 2 | public static class Configuration 3 | { 4 | 5 | public readonly static string[] SupportedCultures = [ 6 | "bg", 7 | "en", 8 | "es", 9 | "fi", 10 | "fr", 11 | "it", 12 | "nl", 13 | "pt", 14 | "sv", 15 | "sw", 16 | "de", 17 | "ca", 18 | ]; 19 | 20 | /// 21 | /// add the custom localization features for the application framework 22 | /// 23 | /// 24 | public static void ConfigureRequestLocalization(this WebApplicationBuilder builder) 25 | { 26 | 27 | var appState = builder.Services.BuildServiceProvider().GetRequiredService(); 28 | var cultures = appState.Localization?.SupportedCultures ?? SupportedCultures; 29 | var defaultCulture = appState.Localization?.DefaultCulture ?? "en"; 30 | builder.Services.Configure(options => 31 | { 32 | options.SetDefaultCulture(defaultCulture) 33 | .AddSupportedCultures(cultures) 34 | .AddSupportedUICultures(cultures); 35 | }); 36 | 37 | builder.Services.AddLocalization(options => 38 | { 39 | options.ResourcesPath = "Locales"; 40 | }); 41 | 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/SharpSite.Web/PluginManagerExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.FileProviders; 2 | using SharpSite.Plugins; 3 | 4 | namespace SharpSite.Web; 5 | 6 | public static class PluginManagerExtensions 7 | { 8 | /// 9 | /// Configure application state and the PluginManager 10 | /// 11 | public static ApplicationState AddPluginManagerAndAppState(this WebApplicationBuilder builder) 12 | { 13 | 14 | PluginManager.Initialize(); 15 | 16 | var appState = new ApplicationState(); 17 | builder.Services.AddSingleton(appState); 18 | builder.Services.AddSingleton(); 19 | builder.Services.AddSingleton(); 20 | 21 | return appState; 22 | 23 | } 24 | 25 | public static WebApplication ConfigurePluginFileSystem(this WebApplication app) 26 | { 27 | 28 | var pluginRoot = new PhysicalFileProvider( 29 | Path.Combine(app.Environment.ContentRootPath, @"plugins/_wwwroot")); 30 | app.UseStaticFiles(); 31 | app.UseStaticFiles(new StaticFileOptions() 32 | { 33 | FileProvider = pluginRoot, 34 | RequestPath = "/plugins" 35 | }); 36 | 37 | return app; 38 | 39 | } 40 | 41 | public static async Task ActivatePluginManager(this WebApplication app, ApplicationState appState) 42 | { 43 | 44 | var pluginManager = app.Services.GetRequiredService(); 45 | await pluginManager.LoadPluginsAtStartup(); 46 | await appState.Load(app.Services); 47 | 48 | return pluginManager; 49 | 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using Microsoft.AspNetCore.SignalR; 3 | using SharpSite.Abstractions; 4 | using SharpSite.Data.Postgres; 5 | using SharpSite.Security.Postgres; 6 | using SharpSite.Web; 7 | using SharpSite.Web.Components; 8 | using SharpSite.Web.Locales; 9 | 10 | var builder = WebApplication.CreateBuilder(args); 11 | 12 | // Load plugins for postgres 13 | #region Postgres Plugins 14 | var pg = new RegisterPostgresServices(); 15 | pg.RegisterServices(builder); 16 | 17 | var pgSecurity = new RegisterPostgresSecurityServices(); 18 | pgSecurity.RegisterServices(builder); 19 | #endregion 20 | 21 | var appState = builder.AddPluginManagerAndAppState(); 22 | 23 | // add the custom localization features for the application framework 24 | builder.ConfigureRequestLocalization(); 25 | 26 | builder.Services.AddHttpContextAccessor(); 27 | 28 | // Add service defaults & Aspire components. 29 | builder.AddServiceDefaults(); 30 | // Configure larger messages to allow upload of packages 31 | builder.Services.Configure(options => 32 | { 33 | options.EnableDetailedErrors = true; 34 | }); 35 | 36 | // Add services to the container. 37 | builder.Services.AddRazorComponents() 38 | .AddInteractiveServerComponents() 39 | .AddCircuitOptions(o => 40 | { 41 | o.DetailedErrors = true; 42 | }); 43 | 44 | 45 | builder.Services.AddOutputCache(); 46 | builder.Services.AddMemoryCache(); 47 | 48 | // add an implementation of IEmailSender that does nothing for SharpSiteUser 49 | builder.Services.AddTransient, IdentityNoOpEmailSender>(); 50 | 51 | var app = builder.Build(); 52 | 53 | if (!app.Environment.IsDevelopment()) 54 | { 55 | app.UseExceptionHandler("/Error", createScopeForErrors: true); 56 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 57 | app.UseHsts(); 58 | } 59 | 60 | app.UseHttpsRedirection(); 61 | 62 | app.ConfigurePluginFileSystem(); 63 | 64 | 65 | app.UseOutputCache(); 66 | 67 | // add error handlers for page not found 68 | app.UseStatusCodePagesWithReExecute("/Error", "?statusCode={0}"); 69 | 70 | var pluginManager = await app.ActivatePluginManager(appState); 71 | 72 | app.MapRazorComponents() 73 | .AddInteractiveServerRenderMode() 74 | .AddAdditionalAssemblies( 75 | typeof(SharpSite.Security.Postgres.PgSharpSiteUser).Assembly 76 | //typeof(Sample.FirstThemePlugin.Theme).Assembly 77 | ); 78 | 79 | app.UseAntiforgery(); 80 | pgSecurity.MapEndpoints(app); 81 | 82 | app.MapSiteMap(); 83 | app.MapRobotsTxt(); 84 | app.MapRssFeed(); 85 | app.MapDefaultEndpoints(); 86 | 87 | app.UseRequestLocalization(); 88 | 89 | await pgSecurity.RunAtStartup(app.Services); 90 | 91 | app.MapFileApi(pluginManager); 92 | 93 | app.Run(); 94 | -------------------------------------------------------------------------------- /src/SharpSite.Web/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "applicationUrl": "http://localhost:5020", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | }, 13 | "https": { 14 | "commandName": "Project", 15 | "dotnetRunMessages": true, 16 | "launchBrowser": true, 17 | "applicationUrl": "https://localhost:7166;http://localhost:5020", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/SharpSite.Web/RobotsTxt.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace SharpSite.Web; 4 | 5 | public static class Program_RobotsTxt 6 | { 7 | 8 | public static WebApplication MapRobotsTxt(this WebApplication app) 9 | { 10 | app.MapGet("/robots.txt", async (HttpContext context, ApplicationState appState) => 11 | { 12 | 13 | context.Response.ContentType = "text/plain"; 14 | 15 | var robotsTextContent = GenerateRobotsTxt($"i{context.Request.Scheme}://{context.Request.Host}", appState); 16 | 17 | await context.Response.WriteAsync(robotsTextContent); 18 | 19 | }).CacheOutput(policy => 20 | { 21 | policy.Tag("robots"); 22 | policy.Expire(TimeSpan.FromDays(30)); 23 | }); 24 | 25 | return app; 26 | } 27 | 28 | internal static string GenerateRobotsTxt(string urlBase, ApplicationState appState) 29 | { 30 | 31 | 32 | var sb = new StringBuilder(); 33 | sb.AppendLine("User-agent: *"); 34 | sb.AppendLine("Disallow: /admin/"); 35 | 36 | if (!string.IsNullOrEmpty(appState.RobotsTxtCustomContent)) 37 | { 38 | sb.AppendLine(appState.RobotsTxtCustomContent); 39 | } 40 | 41 | // add a line for the sitemap using the base URL from the context 42 | sb.AppendLine($"Sitemap: {urlBase}/sitemap.xml"); 43 | 44 | return sb.ToString(); 45 | 46 | 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/SharpSite.Web/RouteValues.cs: -------------------------------------------------------------------------------- 1 | public static class RouteValues 2 | { 3 | public const string AdminPostList = "/admin/posts"; 4 | } 5 | 6 | public record struct RouteValue(string Value, Func? Formatter) 7 | { 8 | 9 | public RouteValue(string value) : this(value, null) { } 10 | 11 | public override string ToString() => Value; 12 | 13 | public static implicit operator string(RouteValue value) => value.ToString(); 14 | 15 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/Rss.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Security; 3 | using System.Text; 4 | using SharpSite.Abstractions; 5 | 6 | namespace SharpSite.Web; 7 | 8 | public static class Program_Rss 9 | { 10 | 11 | public static WebApplication? MapRssFeed(this WebApplication? app) 12 | { 13 | 14 | if (app == null) 15 | { 16 | return null; 17 | } 18 | 19 | app.MapGet("/rss.xml", async (HttpContext context, IPostRepository postRepository) => 20 | { 21 | 22 | var posts = await postRepository.GetPosts(); 23 | 24 | context.Response.StatusCode = (int)HttpStatusCode.OK; 25 | context.Response.ContentType = "application/rss+xml"; 26 | await context.Response.WriteAsync(GenerateRSS($"{context.Request.Scheme}://{context.Request.Host}", posts)); 27 | 28 | }).CacheOutput(policy => 29 | { 30 | policy.Tag("rss"); 31 | policy.Expire(TimeSpan.FromMinutes(30)); 32 | }); 33 | 34 | return app; 35 | 36 | } 37 | 38 | internal static string GenerateRSS(string urlBase, IEnumerable posts) 39 | { 40 | 41 | var sb = new StringBuilder(); 42 | sb.AppendLine(""); 43 | sb.AppendLine(""); 44 | sb.AppendLine(""); 45 | sb.AppendLine("SharpSite"); 46 | 47 | // generate the feed link from the current request using the scheme and host name 48 | sb.AppendLine($"{urlBase}"); 49 | 50 | foreach (var post in posts) 51 | { 52 | 53 | // generate the post link from the current request using the scheme and host name 54 | var postLink = $"{urlBase}{post.ToUrl()}"; 55 | // add the post to the stringbuilder xml document 56 | sb.AppendLine(""); 57 | sb.AppendLine($"{SecurityElement.Escape(post.Title)}"); 58 | sb.AppendLine($"{postLink}"); 59 | sb.AppendLine($"{SecurityElement.Escape(post.Description)}"); 60 | sb.AppendLine($"{post.PublishedDate:R}"); 61 | sb.AppendLine(""); 62 | 63 | } 64 | 65 | sb.AppendLine(""); 66 | sb.AppendLine(""); 67 | 68 | return sb.ToString(); 69 | 70 | } 71 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/SharedResource.cs: -------------------------------------------------------------------------------- 1 | namespace SharpSite.Web; 2 | 3 | /// 4 | /// Dummy class for the Localization feature 5 | /// 6 | public class SharedResource 7 | { 8 | } 9 | -------------------------------------------------------------------------------- /src/SharpSite.Web/SharpSite.Web.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | runtime; build; native; contentfiles; analyzers; buildtransitive 30 | all 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | True 40 | True 41 | SharedResource.resx 42 | 43 | 44 | 45 | 46 | 47 | ResXFileCodeGenerator 48 | SharedResource.Designer.cs 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/SharpSite.Web/SharpsiteConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using SharpSite.Abstractions.Base; 3 | 4 | namespace SharpSite.Web; 5 | 6 | public static class SharpsiteConfigurationExtensions 7 | { 8 | 9 | public static ISharpSiteConfigurationSection CloneSection(this ApplicationState appState, string sectionName) 10 | { 11 | 12 | var theType = appState.ConfigurationSections[sectionName].GetType(); 13 | var json = JsonConvert.SerializeObject(appState.ConfigurationSections[sectionName], 14 | new JsonSerializerSettings 15 | { 16 | TypeNameHandling = TypeNameHandling.Auto, 17 | }); 18 | 19 | return (ISharpSiteConfigurationSection)JsonConvert.DeserializeObject( 20 | json, 21 | theType, 22 | new JsonSerializerSettings 23 | { 24 | TypeNameHandling = TypeNameHandling.Auto, 25 | })!; 26 | 27 | } 28 | 29 | 30 | } -------------------------------------------------------------------------------- /src/SharpSite.Web/Sitemap.cs: -------------------------------------------------------------------------------- 1 | using SharpSite.Abstractions; 2 | using System.Text; 3 | 4 | public static class ProgramExtensions_Sitemap 5 | { 6 | public static WebApplication MapSiteMap(this WebApplication app) 7 | { 8 | app.MapGet("/sitemap.xml", async ( 9 | IHostEnvironment env, 10 | HttpContext context, 11 | IPostRepository postRepository, 12 | IPageRepository pageRepository) => 13 | { 14 | var host = context.Request.Host.Value; 15 | var posts = await postRepository.GetPosts(); 16 | var pages = await pageRepository.GetPages(); 17 | context.Response.ContentType = "application/xml"; 18 | await context.Response.WriteAsync(GenerateSitemap(host, posts, pages)); 19 | }) 20 | .CacheOutput(policy => 21 | { 22 | policy.Tag("sitemap"); 23 | policy.Expire(TimeSpan.FromMinutes(30)); 24 | }); 25 | return app; 26 | 27 | } 28 | 29 | internal static string GenerateSitemap(string? host, IEnumerable posts, IEnumerable pages) 30 | { 31 | 32 | var lastModDate = DateTime.Now.Date; 33 | 34 | var baseXML = $""" 35 | 36 | 37 | 38 | https://{host} 39 | {lastModDate:yyyy-MM-dd} 40 | 41 | """; 42 | 43 | var sb = new StringBuilder(baseXML); 44 | 45 | foreach (var post in posts) 46 | { 47 | var newXml = $""" 48 | 49 | https://{host}{post.ToUrl()} 50 | {post.LastUpdate:yyyy-MM-dd} 51 | 52 | """; 53 | sb.Append(newXml); 54 | } 55 | 56 | foreach (var page in pages) 57 | { 58 | var newXml = $""" 59 | 60 | https://{host}/{page.Slug.ToLowerInvariant()} 61 | {page.LastUpdate:yyyy-MM-dd} 62 | 63 | """; 64 | sb.Append(newXml); 65 | } 66 | 67 | sb.Append(""); 68 | 69 | return sb.ToString(); 70 | 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/SharpSite.Web/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/SharpSite.Web/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /src/SharpSite.Web/wwwroot/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | } 4 | 5 | a, .btn-link { 6 | color: #006bb7; 7 | } 8 | 9 | .btn-primary { 10 | color: #fff; 11 | background-color: #1b6ec2; 12 | border-color: #1861ac; 13 | } 14 | 15 | .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { 16 | box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; 17 | } 18 | 19 | .content { 20 | padding-top: 1.1rem; 21 | } 22 | 23 | h1:focus { 24 | outline: none; 25 | } 26 | 27 | .valid.modified:not([type=checkbox]) { 28 | outline: 1px solid #26b050; 29 | } 30 | 31 | .invalid { 32 | outline: 1px solid #e50200; 33 | } 34 | 35 | .validation-message { 36 | color: #e50200; 37 | } 38 | 39 | .blazor-error-boundary { 40 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 41 | padding: 1rem 1rem 1rem 3.7rem; 42 | color: white; 43 | } 44 | 45 | .blazor-error-boundary::after { 46 | content: "An error has occurred." 47 | } 48 | 49 | .visually-hidden { 50 | clip: rect(0 0 0 0); 51 | clip-path: inset(50%); 52 | height: 1px; 53 | overflow: hidden; 54 | position: absolute; 55 | white-space: nowrap; 56 | width: 1px; 57 | } 58 | -------------------------------------------------------------------------------- /src/SharpSite.Web/wwwroot/app.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | window.getSelectedText = function (cssSelector) { 3 | const editor = document.querySelector(cssSelector); 4 | if (editor) { 5 | const start = editor.selectionStart; 6 | const end = editor.selectionEnd; 7 | return editor.value.substring(start, end); 8 | } 9 | return ''; 10 | }; 11 | })(); -------------------------------------------------------------------------------- /src/SharpSite.Web/wwwroot/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/src/SharpSite.Web/wwwroot/favicon.png -------------------------------------------------------------------------------- /src/SharpSite.Web/wwwroot/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csharpfritz/SharpSite/615b9f9541a3ce5d69d3ec48bf58e5a5211abc5f/src/SharpSite.Web/wwwroot/logo.webp -------------------------------------------------------------------------------- /tests/SharpSite.Tests.Plugins/SharpSite.Tests.Plugins.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/SharpSite.Tests.Web/ApplicationState/BaseFixture.cs: -------------------------------------------------------------------------------- 1 | using SUT = SharpSite.Web.ApplicationState; 2 | 3 | 4 | namespace SharpSite.Tests.Web.ApplicationState; 5 | 6 | public abstract class BaseFixture 7 | { 8 | protected SUT ApplicationState { get; set; } 9 | protected BaseFixture() 10 | { 11 | ApplicationState = new SUT(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/SharpSite.Tests.Web/ApplicationState/Load/WhenFileDoesNotExist.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.SignalR; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Options; 4 | using Xunit; 5 | 6 | namespace SharpSite.Tests.Web.ApplicationState.Load; 7 | 8 | public class WhenFileDoesNotExist : BaseFixture 9 | { 10 | 11 | [Fact] 12 | public async Task ShouldNotInitialize() 13 | { 14 | // Arrange 15 | var services = new ServiceCollection(); 16 | var hubOptions = Options.Create(new HubOptions()); 17 | services.AddSingleton(hubOptions); 18 | var serviceProvider = services.BuildServiceProvider(); 19 | 20 | // Act 21 | await ApplicationState.Load(serviceProvider, () => string.Empty); 22 | 23 | // Assert 24 | Assert.False(ApplicationState.Initialized); 25 | 26 | } 27 | } -------------------------------------------------------------------------------- /tests/SharpSite.Tests.Web/ApplicationState/Load/WhenFileExists.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.SignalR; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Options; 4 | using Newtonsoft.Json; 5 | using Xunit; 6 | using SUT = SharpSite.Web.ApplicationState; 7 | 8 | namespace SharpSite.Tests.Web.ApplicationState.Load; 9 | 10 | public class WhenFileExists : BaseFixture 11 | { 12 | [Fact] 13 | public async Task ShouldInitializeState() 14 | { 15 | // Arrange 16 | var services = new ServiceCollection(); 17 | var hubOptions = Options.Create(new HubOptions()); 18 | services.AddSingleton(hubOptions); 19 | var serviceProvider = services.BuildServiceProvider(); 20 | 21 | var state = new SUT 22 | { 23 | MaximumUploadSizeMB = 20, 24 | PageNotFoundContent = "Not Found", 25 | Localization = new SUT.LocalizationRecord("en-US", new[] { "en-US", "fr-FR" }), 26 | CurrentTheme = new SUT.CurrentThemeRecord("theme-v1") 27 | }; 28 | 29 | var json = JsonConvert.SerializeObject(state, new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto }); 30 | 31 | // Act 32 | await ApplicationState.Load(serviceProvider, () => json); 33 | 34 | // Assert 35 | Assert.True(ApplicationState.Initialized); 36 | Assert.Equal(20, ApplicationState.MaximumUploadSizeMB); 37 | Assert.Equal("Not Found", ApplicationState.PageNotFoundContent); 38 | Assert.Equal("en-US", ApplicationState.Localization?.DefaultCulture); 39 | Assert.Equal(new[] { "en-US", "fr-FR" }, ApplicationState.Localization?.SupportedCultures); 40 | Assert.Equal("theme-v1", ApplicationState.CurrentTheme?.IdVersion); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /tests/SharpSite.Tests.Web/ApplicationState/SetConfigurationSection/WhenAddingNewSection.cs: -------------------------------------------------------------------------------- 1 | using Moq; 2 | using SharpSite.Abstractions.Base; 3 | using Xunit; 4 | 5 | 6 | namespace SharpSite.Tests.Web.ApplicationState.SetConfigurationSection; 7 | 8 | public class WhenAddingNewSection : BaseFixture 9 | { 10 | [Fact] 11 | public async Task ThenSectionIsAdded() 12 | { 13 | // Arrange 14 | var sectionMock = new Mock(); 15 | sectionMock.Setup(s => s.SectionName).Returns("TestSection"); 16 | var section = sectionMock.Object; 17 | 18 | // Act 19 | await ApplicationState.SetConfigurationSection(section); 20 | 21 | // Assert 22 | Assert.Contains(section, ApplicationState.ConfigurationSections.Values); 23 | } 24 | 25 | [Fact] 26 | public async Task ThenEventHandlerIsTriggered() 27 | { 28 | // Arrange 29 | var sectionMock = new Mock(); 30 | sectionMock.Setup(s => s.SectionName).Returns("TestSection"); 31 | var section = sectionMock.Object; 32 | var eventHandlerTriggered = false; 33 | ApplicationState.ConfigurationSectionChanged += async (sender, args) => 34 | { 35 | eventHandlerTriggered = true; 36 | await Task.CompletedTask; 37 | }; 38 | 39 | // Act 40 | await ApplicationState.SetConfigurationSection(section); 41 | 42 | // Assert 43 | Assert.True(eventHandlerTriggered); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/SharpSite.Tests.Web/ApplicationState/SetConfigurationSection/WhenSettingNullSection.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | 4 | namespace SharpSite.Tests.Web.ApplicationState.SetConfigurationSection; 5 | 6 | public class WhenSettingNullSection : BaseFixture 7 | { 8 | [Fact] 9 | public async Task ThenThrowsArgumentNullException() 10 | { 11 | // Act 12 | async Task Act() => await ApplicationState.SetConfigurationSection(null!); 13 | 14 | // Assert 15 | await Assert.ThrowsAsync(Act); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/SharpSite.Tests.Web/RSS/GenerateRSS.cs: -------------------------------------------------------------------------------- 1 | using SharpSite.Abstractions; 2 | using SharpSite.Web; 3 | using System.Security; 4 | using Xunit; 5 | 6 | namespace SharpSite.Tests.Web.RSS; 7 | 8 | public class GenerateRss 9 | { 10 | [Fact] 11 | public void ReturnsValidRssFormat() 12 | { 13 | 14 | // Arrange 15 | var urlBase = "http://localhost"; 16 | IEnumerable posts = [ 17 | new Post 18 | { 19 | Slug = "test-post-1", 20 | Title = "Test Post 1", 21 | Description = "Description for Test Post 1", 22 | Content = "Content for Test Post 1", 23 | PublishedDate = DateTimeOffset.Now, 24 | LastUpdate = DateTimeOffset.Now, 25 | LanguageCode = "en" 26 | }, 27 | new Post 28 | { 29 | Slug = "test-post-2", 30 | Title = "Test Post 2", 31 | Description = "Description for Test Post 2", 32 | Content = "Content for Test Post 2", 33 | PublishedDate = DateTimeOffset.Now, 34 | LastUpdate = DateTimeOffset.Now, 35 | LanguageCode = "en" 36 | } 37 | ]; 38 | 39 | // Act 40 | var rss = Program_Rss.GenerateRSS(urlBase, posts); 41 | 42 | // Assert 43 | Assert.Contains("", rss); 44 | Assert.Contains("", rss); 45 | Assert.Contains("", rss); 46 | Assert.Contains("SharpSite", rss); 47 | Assert.Contains("http://localhost", rss); 48 | foreach (var post in posts) 49 | { 50 | Assert.Contains($"{SecurityElement.Escape(post.Title)}", rss); 51 | Assert.Contains($"http://localhost{post.ToUrl()}", rss); 52 | Assert.Contains($"{SecurityElement.Escape(post.Description)}", rss); 53 | Assert.Contains($"{post.PublishedDate:R}", rss); 54 | } 55 | Assert.Contains("", rss); 56 | Assert.Contains("", rss); 57 | 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /tests/SharpSite.Tests.Web/RobotsTxt/GenerateRobotsTxt.cs: -------------------------------------------------------------------------------- 1 | using SharpSite.Web; 2 | using Xunit; 3 | using WEB = SharpSite.Web; 4 | 5 | namespace SharpSite.Tests.Web.RobotsTxt; 6 | 7 | public class GenerateRobotsTxt 8 | { 9 | [Fact] 10 | public void ShouldIncludeDisallowAdmin() 11 | { 12 | // Arrange 13 | var urlBase = "http://example.com"; 14 | var appState = new WEB.ApplicationState(); 15 | 16 | // Act 17 | var result = Program_RobotsTxt.GenerateRobotsTxt(urlBase, appState); 18 | 19 | // Assert 20 | Assert.Contains("Disallow: /admin/", result); 21 | } 22 | 23 | [Fact] 24 | public void ShouldIncludeSitemap() 25 | { 26 | // Arrange 27 | var urlBase = "http://example.com"; 28 | var appState = new WEB.ApplicationState(); 29 | 30 | // Act 31 | var result = Program_RobotsTxt.GenerateRobotsTxt(urlBase, appState); 32 | 33 | // Assert 34 | Assert.Contains($"Sitemap: {urlBase}/sitemap.xml", result); 35 | } 36 | 37 | [Fact] 38 | public void ShouldIncludeCustomContent() 39 | { 40 | // Arrange 41 | var urlBase = "http://example.com"; 42 | var appState = new WEB.ApplicationState 43 | { 44 | RobotsTxtCustomContent = "Custom content" 45 | }; 46 | 47 | // Act 48 | var result = Program_RobotsTxt.GenerateRobotsTxt(urlBase, appState); 49 | 50 | // Assert 51 | Assert.Contains("Custom content", result); 52 | } 53 | 54 | [Fact] 55 | public void ShouldNotIncludeCustomContent_WhenEmpty() 56 | { 57 | // Arrange 58 | var urlBase = "http://example.com"; 59 | var appState = new WEB.ApplicationState 60 | { 61 | RobotsTxtCustomContent = string.Empty 62 | }; 63 | 64 | // Act 65 | var result = Program_RobotsTxt.GenerateRobotsTxt(urlBase, appState); 66 | 67 | // Assert 68 | Assert.DoesNotContain("Custom content", result); 69 | } 70 | } -------------------------------------------------------------------------------- /tests/SharpSite.Tests.Web/SharpSite.Tests.Web.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | all 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/SharpSite.Tests.Web/Sitemap/GenerateSitemap.cs: -------------------------------------------------------------------------------- 1 | using SharpSite.Abstractions; 2 | using System.Text.RegularExpressions; 3 | using Xunit; 4 | 5 | namespace SharpSite.Tests.Web.Sitemap; 6 | public partial class GenerateSitemap 7 | { 8 | private const string _Host = "example.com"; 9 | 10 | [Fact] 11 | public void ShouldReturnValidXml() 12 | { 13 | // Arrange 14 | var now = DateTimeOffset.Now; 15 | IEnumerable posts = [ 16 | new Post { PublishedDate = now, Slug = "post-1", Title = "Post 1", Content = "Content 1" }, 17 | new Post { PublishedDate = now, Slug = "post-2", Title = "Post 2", Content = "Content 2" } 18 | ]; 19 | 20 | IEnumerable pages = [ 21 | new Page { LastUpdate = now, Slug = "page-1" }, 22 | new Page { LastUpdate = now, Slug = "page-2" } 23 | ]; 24 | 25 | // Act 26 | var result = ProgramExtensions_Sitemap.GenerateSitemap(_Host, posts, pages); 27 | 28 | // Assert 29 | Assert.NotNull(result); 30 | Assert.Contains("https://example.com", result); 31 | Assert.Contains($"https://example.com/{now.UtcDateTime:yyyyMMdd}/post-1", result); 32 | Assert.Contains($"https://example.com/{now.UtcDateTime:yyyyMMdd}/post-2", result); 33 | Assert.Contains("https://example.com/page-1", result); 34 | Assert.Contains("https://example.com/page-2", result); 35 | } 36 | 37 | [Fact] 38 | public void ShouldHandleEmptyRepositories() 39 | { 40 | // Act 41 | var result = ProgramExtensions_Sitemap.GenerateSitemap(_Host, [], []); 42 | 43 | // Assert 44 | Assert.NotNull(result); 45 | Assert.Contains("https://example.com", result); 46 | Assert.DoesNotMatch(PostUrlRegex(), result); 47 | Assert.DoesNotContain("https://example.com/page-", result); 48 | } 49 | 50 | [GeneratedRegex(@"https://example.com/\d{8}/post-")] 51 | private static partial Regex PostUrlRegex(); 52 | } 53 | --------------------------------------------------------------------------------