From 045deccb12f74b529a6fbe1daf1cf05f63551452 Mon Sep 17 00:00:00 2001 From: Calle <22471295+calledude@users.noreply.github.com> Date: Tue, 26 Mar 2024 17:00:06 +0100 Subject: [PATCH] Migrate frontend to Blazor (#992) * Initial blazor implementation * Remove template files * Add developmentSettings to gitignore and allow wwwroot in blazor project * Add MudBlazor to _Host.cshtml * Initial landing page implementation * Migrate Modix startup to Modix.Web Add Authorization policies based on AuthorizationClaim enum Add crude implementation of DiscordUserService * Implement navbar with separate views depending on authorization state * Initial implementation of UserLookup page * Initial implementation of Commands page * Initial implementation of Stats page * Initial implementation of Tags page * Initial implementation of Promotions page * Run AuthorizationService.OnAuthenticatedAsync in MainLayout instead ClaimsTransformation uses a different service scope from the blazor circuit - which leads to AuthorizationService properties not being set properly for the authenticated user Also remove redundant authorization setup in startup * Initial implementation of Logs/Infractions page * Use dropdown for 'Type' in infractions grid * Remove unnecessary comments * Move navbar from MainLayout to separate component * Change page titles * Get SocketGuild from SocketGuildUser instead * Use OnAfterRenderAsync instead to avoid duplicate DB calls * Implement guild selection Remove ClaimsTransformationService Overall improvements to navbar Move logic for adding claims to App.razor Read selected guild from cookie and pass it down the chain * Remove etc in favor of Authorize attribute * Use OnAfterRenderAsync to avoid unnecessary execution of db calls etc * Use [Authorize] attribute to avoid rendering of the page whatsoever * Fix icon colors of navbar in the logged out state * Use middleware to assign claims rather than have it in App.razor The call to AuthorizationService.OnAuthenticatedAsync is still needed in App.razor to be able to set the values in the correct scope. * Move logout button to dropdown menu Small tidy up * Rename LocalStorageService -> CookieService * Show current user when first visiting UserLookup Slight improvements with nullability Remove commented code * Move models to Models folder * Propagate CurrentUserId down to components via SessionState to avoid reading from user claims constantly * Fix issue with filters not resetting properly * Initially select nothing in the dropdown for InfractionType (nothing was actually initially selected, it just appeared to be) * Force cookie to be created on root path * Continue with first found guild if no guild cookie was found * Case-insensitive filtering on Tags page * Improve feedback when Tag creation fails (or succeeds) * Allow null/empty comments for promotions Use MudSpacer instead of manual flex-grow * Disable editing comments for closed campaigns Update UI after editing comment Better feedback after editing comment * Initial implementation of Configuration (Claims) * Small touchup * Remove unnecessary stuff in MainLayout * Modify DesignatedRoleService API slightly to return the id of the created entity * Initial implementation of Configuration (Roles) * Slight improvement to styling of menu for Configuration and Logs * Modify DesignatedChannelService API slightly to return the id of the created entity * Make IndividualDesignation component generic * Initial implementation of Configuration (Channels) * Slight improvements to Channels/Roles configuration pages * Make Autocomplete component generic * Move ModixUser and RoleInformation into Common * Modify interface of PromotionsService to return the PromotionActionSummary after updating a comment Fix bug in Promotions UI where you could edit a comment and inadvertently have two active ones as a result * Implement possibility of passing query parameters to infractions page, such as ?id= and ?subject= Also redirect from /infractions to /logs/infractions * Add possibility of accepting/rejecting/forcing a campaign as well as link to the infractions page for the campaign subject * Simplify styling on Infractions page * Use iconbuttons instead for accept/reject * Re-use existing extension method for getting full username * Initial implementation of Logs/DeletedMessages page * Fix bug with filtering DeletedMessageEntity - When Batch is set, use that to filter on CreatedBy(Id) - Move AsExpandable() to sourceQuery * Set _currentContext before potential early bail Close dialog on error * Show role color if relevant in Tags grid * Enable query parameter for Tags page * Use generic version of DialogParameters when instantiating Dialogs * Persist infraction table settings to local storage Move cookie constants into separate class * Slight styling improvements to stats page * Fix/consolidate date string formatting * Responsive navmenu * Responsive commands page * Better styling for DeletedMessages * Better styling for Infractions page * Responsive styling for Configuration (Claims) * Responsive styling for UserLookup * Slightly improve styling for landing and promotions pages * Remove unnecessary elements from landing page * Change Primary color to match the vue website * Fix weird flickering issue when navigating via anchors * Use MudGrid instead of MudDrawer Improve styling on Commands page Fix element Ids not being navigable to because of having spaces in them * Rename DiscordUserService to DiscordHelper * Make it possible to switch between Vue and Blazor via config flag 'UseBlazor' Convert Startup based startup to minimal hosting model in Modix Make Modix.Web into a class library * Remove appsettings from Modix.Web * Add NoDefaultLaunchSettingsFile to avoid re-creating launchSettings.json * Remove LocalStorageService in favor of reading cookies upon first HTTP request to the site and propagating settings through SessionState instead * Remove unused package/project references * Add favicon * Use Roles instead of Policy authorization * Improve grid size for bigger screens * Implement comment creation box to show when the user has yet to vote on a campaign * Fit content to screen width on Tags page * Minor touchups * Remove unused css * Fix more nullability stuff Some of it might technically be impossible scenarios, but "meh" :) * Fixed AutoComplete after breaking it by not invoking the RenderFragment after fixing nullability * Disable button/comment box on promotion creation page if no "next rank" is available * Fix compilation issue * Upgrade packages to stable version to fix startup issue * Fix ItemTemplate issue causing preview to be empty * Remove TODO comments, add error message when fetching of campaign details fails * Make Title an optional parameter on the AutoComplete component and remove the usage of it on the UserLookup page * Remove background color from UserLookup * Apply small gap rule on small screen sizes (that aren't quite xs yet) to avoid overlapping between elements * Improved styling for toolbar on Infractions page * Improved styling for toolbar on Tags page * Fix css not being served from the correct folder * Improve styling for Promotions page on small resolutions * Update NuGet packages and change from deprecated APIs/properties * Remove UseStaticFiles call in favor of linking to static files correctly in the _Host file * Update Dockerfile to use .net8 instead of .net8-preview image * Update more NuGet packages to .NET pinned versions and cleanup some of the version references in projects * Use maxcpucount:1 in Dockerfile to avoid files being used by other processes error * Slight mobile styling tweaks to Infractions and Tags page * Reduce reliance on inline styling * Update default value for UseBlazor setting in deployment configuration files * Format code * Improved styling on CreatePromotion page on small resolutions * Improved styling for Configuration/Logs pages * Add margin to make it look a bit nicer when scrolling to the bottom of the page * Tweak styling for large resolutions on Stats and Logs pages --- .gitignore | 5 +- Directory.Build.targets | 47 +- Dockerfile | 8 +- Modix.Bot.Test/Modix.Bot.Test.csproj | 2 +- .../Assertions/DbContextSequenceExtensions.cs | 4 +- Modix.Data/Models/Core/ModixConfig.cs | 5 +- .../DeletedMessageSearchCriteria.cs | 10 +- .../Repositories/DeletedMessageRepository.cs | 3 +- .../Modix.Services.Test.csproj | 2 +- .../Core/DesignatedChannelService.cs | 25 +- Modix.Services/Core/DesignatedRoleService.cs | 8 +- .../Promotions/PromotionsService.cs | 12 +- .../Utilities/DiscordWebhookSink.cs | 18 +- Modix.Web/App.razor | 79 +++ Modix.Web/Components/AnchorNavigation.razor | 39 ++ Modix.Web/Components/AutoComplete.razor | 24 + Modix.Web/Components/AutoComplete.razor.cs | 25 + .../Components/Configuration/Channels.razor | 171 +++++++ .../Components/Configuration/Claims.razor | 166 +++++++ .../Configuration/IndividualDesignation.razor | 66 +++ .../Components/Configuration/Roles.razor | 171 +++++++ Modix.Web/Components/ConfirmationDialog.razor | 29 ++ .../Components/CreateCampaignComment.razor | 40 ++ Modix.Web/Components/DeletedMessages.razor | 281 +++++++++++ .../EditPromotionCommentDialog.razor | 42 ++ Modix.Web/Components/Infractions.razor | 455 ++++++++++++++++++ Modix.Web/Components/UserLookupField.razor | 35 ++ Modix.Web/Models/Commands/Command.cs | 5 + Modix.Web/Models/Commands/Module.cs | 3 + Modix.Web/Models/Common/ChannelInformation.cs | 3 + Modix.Web/Models/Common/IAutoCompleteItem.cs | 6 + Modix.Web/Models/Common/ModixUser.cs | 25 + Modix.Web/Models/Common/RoleInformation.cs | 3 + .../Configuration/DesignatedChannelData.cs | 5 + .../Configuration/DesignatedRoleData.cs | 5 + Modix.Web/Models/CookieConstants.cs | 9 + .../DeletedMessageInformation.cs | 27 ++ .../Models/DeletedMessages/TableFilter.cs | 91 ++++ Modix.Web/Models/GuildOption.cs | 3 + .../Models/Infractions/InfractionData.cs | 43 ++ Modix.Web/Models/Infractions/TableFilter.cs | 62 +++ .../Models/Promotions/CampaignCommentData.cs | 5 + Modix.Web/Models/Promotions/NextRank.cs | 3 + Modix.Web/Models/SessionState.cs | 10 + Modix.Web/Models/Stats/GuildStatData.cs | 6 + Modix.Web/Models/Tags/TagData.cs | 32 ++ .../MessageCountPerChannelInformation.cs | 3 + .../Models/UserLookup/UserInformation.cs | 62 +++ Modix.Web/Modix.Web.csproj | 21 + Modix.Web/Pages/Commands.razor | 179 +++++++ Modix.Web/Pages/Configuration.razor | 61 +++ Modix.Web/Pages/CreatePromotion.razor | 128 +++++ Modix.Web/Pages/Error.cshtml | 42 ++ Modix.Web/Pages/Error.cshtml.cs | 16 + Modix.Web/Pages/Index.razor | 23 + Modix.Web/Pages/Logs.razor | 66 +++ Modix.Web/Pages/Promotions.razor | 350 ++++++++++++++ Modix.Web/Pages/Stats.razor | 116 +++++ Modix.Web/Pages/Tags.razor | 189 ++++++++ Modix.Web/Pages/UserLookup.razor | 182 +++++++ Modix.Web/Pages/_Host.cshtml | 46 ++ Modix.Web/Security/ClaimsMiddleware.cs | 38 ++ Modix.Web/Services/CookieService.cs | 34 ++ Modix.Web/Services/DiscordHelper.cs | 94 ++++ Modix.Web/Setup.cs | 55 +++ Modix.Web/Shared/MainLayout.razor | 37 ++ Modix.Web/Shared/MiniUser.razor | 93 ++++ Modix.Web/Shared/NavMenu.razor | 43 ++ Modix.Web/Shared/NavMenuLinks.razor | 52 ++ Modix.Web/_Imports.razor | 10 + Modix.Web/wwwroot/css/site.css | 17 + Modix.Web/wwwroot/favicon.ico | Bin 0 -> 1150 bytes Modix.sln | 16 +- Modix/Modix.csproj | 1 + Modix/Program.cs | 240 +++++++-- Modix/Startup.cs | 153 ------ Modix/developmentSettings.default.json | 3 +- docker-stack.yml | 1 + 78 files changed, 4217 insertions(+), 272 deletions(-) create mode 100644 Modix.Web/App.razor create mode 100644 Modix.Web/Components/AnchorNavigation.razor create mode 100644 Modix.Web/Components/AutoComplete.razor create mode 100644 Modix.Web/Components/AutoComplete.razor.cs create mode 100644 Modix.Web/Components/Configuration/Channels.razor create mode 100644 Modix.Web/Components/Configuration/Claims.razor create mode 100644 Modix.Web/Components/Configuration/IndividualDesignation.razor create mode 100644 Modix.Web/Components/Configuration/Roles.razor create mode 100644 Modix.Web/Components/ConfirmationDialog.razor create mode 100644 Modix.Web/Components/CreateCampaignComment.razor create mode 100644 Modix.Web/Components/DeletedMessages.razor create mode 100644 Modix.Web/Components/EditPromotionCommentDialog.razor create mode 100644 Modix.Web/Components/Infractions.razor create mode 100644 Modix.Web/Components/UserLookupField.razor create mode 100644 Modix.Web/Models/Commands/Command.cs create mode 100644 Modix.Web/Models/Commands/Module.cs create mode 100644 Modix.Web/Models/Common/ChannelInformation.cs create mode 100644 Modix.Web/Models/Common/IAutoCompleteItem.cs create mode 100644 Modix.Web/Models/Common/ModixUser.cs create mode 100644 Modix.Web/Models/Common/RoleInformation.cs create mode 100644 Modix.Web/Models/Configuration/DesignatedChannelData.cs create mode 100644 Modix.Web/Models/Configuration/DesignatedRoleData.cs create mode 100644 Modix.Web/Models/CookieConstants.cs create mode 100644 Modix.Web/Models/DeletedMessages/DeletedMessageInformation.cs create mode 100644 Modix.Web/Models/DeletedMessages/TableFilter.cs create mode 100644 Modix.Web/Models/GuildOption.cs create mode 100644 Modix.Web/Models/Infractions/InfractionData.cs create mode 100644 Modix.Web/Models/Infractions/TableFilter.cs create mode 100644 Modix.Web/Models/Promotions/CampaignCommentData.cs create mode 100644 Modix.Web/Models/Promotions/NextRank.cs create mode 100644 Modix.Web/Models/SessionState.cs create mode 100644 Modix.Web/Models/Stats/GuildStatData.cs create mode 100644 Modix.Web/Models/Tags/TagData.cs create mode 100644 Modix.Web/Models/UserLookup/MessageCountPerChannelInformation.cs create mode 100644 Modix.Web/Models/UserLookup/UserInformation.cs create mode 100644 Modix.Web/Modix.Web.csproj create mode 100644 Modix.Web/Pages/Commands.razor create mode 100644 Modix.Web/Pages/Configuration.razor create mode 100644 Modix.Web/Pages/CreatePromotion.razor create mode 100644 Modix.Web/Pages/Error.cshtml create mode 100644 Modix.Web/Pages/Error.cshtml.cs create mode 100644 Modix.Web/Pages/Index.razor create mode 100644 Modix.Web/Pages/Logs.razor create mode 100644 Modix.Web/Pages/Promotions.razor create mode 100644 Modix.Web/Pages/Stats.razor create mode 100644 Modix.Web/Pages/Tags.razor create mode 100644 Modix.Web/Pages/UserLookup.razor create mode 100644 Modix.Web/Pages/_Host.cshtml create mode 100644 Modix.Web/Security/ClaimsMiddleware.cs create mode 100644 Modix.Web/Services/CookieService.cs create mode 100644 Modix.Web/Services/DiscordHelper.cs create mode 100644 Modix.Web/Setup.cs create mode 100644 Modix.Web/Shared/MainLayout.razor create mode 100644 Modix.Web/Shared/MiniUser.razor create mode 100644 Modix.Web/Shared/NavMenu.razor create mode 100644 Modix.Web/Shared/NavMenuLinks.razor create mode 100644 Modix.Web/_Imports.razor create mode 100644 Modix.Web/wwwroot/css/site.css create mode 100644 Modix.Web/wwwroot/favicon.ico delete mode 100644 Modix/Startup.cs diff --git a/.gitignore b/.gitignore index 0639821a0..ecf9e659a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ bld/ .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot wwwroot/ +!Modix.Web/wwwroot/ dataprotection/ # VS Code @@ -261,5 +262,5 @@ Modix/config /Modix/Properties/launchSettings.json *.DotSettings -Modix/developmentSettings\.json -Modix/logs/* +**/developmentSettings\.json +**/logs/* diff --git a/Directory.Build.targets b/Directory.Build.targets index f93092695..b430fc594 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -14,28 +14,28 @@ - - - - + + + + - + - - - - - - - - - - - - - + + + + + + + + + + + + + @@ -44,13 +44,12 @@ - - - - - + + + + - + diff --git a/Dockerfile b/Dockerfile index 48a295687..d660ecede 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0-preview AS base +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base WORKDIR /app -FROM mcr.microsoft.com/dotnet/sdk:8.0-preview AS dotnet-build-base +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS dotnet-build-base WORKDIR /src RUN printf 'Package: nodejs\nPin: origin deb.nodesource.com\nPin-Priority: 600\n' > /etc/apt/preferences.d/nodesource RUN apt-get update && apt-get install curl -y \ @@ -15,13 +15,13 @@ RUN dotnet restore Modix.sln COPY . . FROM dotnet-build-base AS dotnet-build -RUN dotnet build -c Release --no-restore Modix.sln +RUN dotnet build -maxcpucount:1 -c Release --no-restore Modix.sln FROM dotnet-build as dotnet-test RUN dotnet test -c Release --no-build --no-restore Modix.sln FROM dotnet-build AS publish -RUN dotnet publish -c Release --no-build --no-restore -o /app Modix/Modix.csproj +RUN dotnet publish -maxcpucount:1 -c Release --no-build --no-restore -o /app Modix/Modix.csproj FROM base AS final COPY --from=publish /app . diff --git a/Modix.Bot.Test/Modix.Bot.Test.csproj b/Modix.Bot.Test/Modix.Bot.Test.csproj index dcaac6542..ad98b49e2 100644 --- a/Modix.Bot.Test/Modix.Bot.Test.csproj +++ b/Modix.Bot.Test/Modix.Bot.Test.csproj @@ -6,7 +6,7 @@ - + diff --git a/Modix.Data.Test/Assertions/DbContextSequenceExtensions.cs b/Modix.Data.Test/Assertions/DbContextSequenceExtensions.cs index 11632aa84..a9684e353 100644 --- a/Modix.Data.Test/Assertions/DbContextSequenceExtensions.cs +++ b/Modix.Data.Test/Assertions/DbContextSequenceExtensions.cs @@ -86,8 +86,8 @@ private static ResettableSequenceValueGenerator GetGetResettableSeque .GetOrAdd(property, entity, valueGeneratorConstructor); } - private static readonly Dictionary> _valueGeneratorConstructorsByValueType - = new Dictionary>() + private static readonly Dictionary> _valueGeneratorConstructorsByValueType + = new Dictionary>() { [typeof(long)] = (p, e) => new ResettableInt64SequenceValueGenerator() }; diff --git a/Modix.Data/Models/Core/ModixConfig.cs b/Modix.Data/Models/Core/ModixConfig.cs index a169f47df..2c48b18d5 100644 --- a/Modix.Data/Models/Core/ModixConfig.cs +++ b/Modix.Data/Models/Core/ModixConfig.cs @@ -1,6 +1,4 @@ -using System; - -namespace Modix.Data.Models.Core +namespace Modix.Data.Models.Core { public class ModixConfig { @@ -31,5 +29,6 @@ public class ModixConfig public string WebsiteBaseUrl { get; set; } = "https://mod.gg"; public bool EnableStatsd { get; set; } + public bool UseBlazor { get; set; } } } diff --git a/Modix.Data/Models/Moderation/DeletedMessageSearchCriteria.cs b/Modix.Data/Models/Moderation/DeletedMessageSearchCriteria.cs index 072306b3d..00adfbd63 100644 --- a/Modix.Data/Models/Moderation/DeletedMessageSearchCriteria.cs +++ b/Modix.Data/Models/Moderation/DeletedMessageSearchCriteria.cs @@ -132,10 +132,16 @@ public static IQueryable FilterBy(this IQueryable ReusableQueries.StringContainsUser.Invoke(x.Author, criteria!.Author!), !string.IsNullOrWhiteSpace(criteria?.Author)) .FilterBy( - x => x.CreateAction.CreatedById == criteria!.CreatedById, + x => x.Batch == null + ? x.CreateAction.CreatedById == criteria!.CreatedById + : x.Batch.CreateAction.CreatedById == criteria!.CreatedById, criteria?.CreatedById != null) .FilterBy( - x => ReusableQueries.StringContainsUser.Invoke(x.CreateAction.CreatedBy!, criteria!.CreatedBy!), + x => ReusableQueries.StringContainsUser.Invoke( + x.Batch == null + ? x.CreateAction.CreatedBy! + : x.Batch.CreateAction.CreatedBy, + criteria!.CreatedBy!), !string.IsNullOrWhiteSpace(criteria?.CreatedBy)) .FilterBy( x => ReusableQueries.DbCaseInsensitiveContains.Invoke(x.Content, criteria!.Content!), diff --git a/Modix.Data/Repositories/DeletedMessageRepository.cs b/Modix.Data/Repositories/DeletedMessageRepository.cs index 9515409cb..770de2bc0 100644 --- a/Modix.Data/Repositories/DeletedMessageRepository.cs +++ b/Modix.Data/Repositories/DeletedMessageRepository.cs @@ -93,13 +93,12 @@ public async Task CreateAsync( public async Task> SearchSummariesPagedAsync( DeletedMessageSearchCriteria searchCriteria, IEnumerable sortingCriteria, PagingCriteria pagingCriteria) { - var sourceQuery = ModixContext.Set().AsNoTracking(); + var sourceQuery = ModixContext.Set().AsNoTracking().AsExpandable(); var filteredQuery = sourceQuery .FilterBy(searchCriteria); var pagedQuery = filteredQuery - .AsExpandable() .Select(DeletedMessageSummary.FromEntityProjection) .SortBy(sortingCriteria, DeletedMessageSummary.SortablePropertyMap) .OrderThenBy(x => x.MessageId, SortDirection.Ascending) diff --git a/Modix.Services.Test/Modix.Services.Test.csproj b/Modix.Services.Test/Modix.Services.Test.csproj index 4ea6f08f8..d80e63abf 100644 --- a/Modix.Services.Test/Modix.Services.Test.csproj +++ b/Modix.Services.Test/Modix.Services.Test.csproj @@ -6,7 +6,7 @@ - + diff --git a/Modix.Services/Core/DesignatedChannelService.cs b/Modix.Services/Core/DesignatedChannelService.cs index 1bf3965a7..cc7f62129 100644 --- a/Modix.Services/Core/DesignatedChannelService.cs +++ b/Modix.Services/Core/DesignatedChannelService.cs @@ -1,15 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; - using Discord; - -using Serilog; - using Modix.Data.Models.Core; using Modix.Data.Repositories; -using System.Threading; +using Serilog; namespace Modix.Services.Core { @@ -25,7 +22,7 @@ public interface IDesignatedChannelService /// The channel to be assigned. /// The type of designation to be assigned. /// A that will complete when the operation has completed. - Task AddDesignatedChannelAsync(IGuild guild, IMessageChannel channel, DesignatedChannelType type); + Task AddDesignatedChannelAsync(IGuild guild, IMessageChannel channel, DesignatedChannelType type); /// /// Unassigns a channel's previously given designation, for a given guild. @@ -117,7 +114,7 @@ public DesignatedChannelService(IDesignatedChannelMappingRepository designatedCh } /// - public async Task AddDesignatedChannelAsync(IGuild guild, IMessageChannel logChannel, DesignatedChannelType type) + public async Task AddDesignatedChannelAsync(IGuild guild, IMessageChannel logChannel, DesignatedChannelType type) { AuthorizationService.RequireAuthenticatedUser(); AuthorizationService.RequireClaims(AuthorizationClaim.DesignatedChannelMappingCreate); @@ -135,7 +132,7 @@ public async Task AddDesignatedChannelAsync(IGuild guild, IMessageChannel logCha throw new InvalidOperationException($"{logChannel.Name} in {guild.Name} is already assigned to {type}"); } - await DesignatedChannelMappingRepository.CreateAsync(new DesignatedChannelMappingCreationData() + var id = await DesignatedChannelMappingRepository.CreateAsync(new DesignatedChannelMappingCreationData() { GuildId = guild.Id, ChannelId = logChannel.Id, @@ -144,6 +141,8 @@ await DesignatedChannelMappingRepository.CreateAsync(new DesignatedChannelMappin }); transaction.Commit(); + + return id; } } @@ -205,11 +204,11 @@ public Task AnyDesignatedChannelAsync(ulong guildId, DesignatedChannelType /// public Task> GetDesignatedChannelIdsAsync(ulong guildId, DesignatedChannelType type) => DesignatedChannelMappingRepository.SearchChannelIdsAsync(new DesignatedChannelMappingSearchCriteria() - { - GuildId = guildId, - Type = type, - IsDeleted = false - }); + { + GuildId = guildId, + Type = type, + IsDeleted = false + }); /// public async Task> GetDesignatedChannelsAsync(IGuild guild, DesignatedChannelType type) diff --git a/Modix.Services/Core/DesignatedRoleService.cs b/Modix.Services/Core/DesignatedRoleService.cs index d5a12e415..0df61133e 100644 --- a/Modix.Services/Core/DesignatedRoleService.cs +++ b/Modix.Services/Core/DesignatedRoleService.cs @@ -21,7 +21,7 @@ public interface IDesignatedRoleService /// The Discord snowflake ID of the role being designated /// The type of designation to be made /// A that will complete when the operation has completed. - Task AddDesignatedRoleAsync(ulong guildId, ulong roleId, DesignatedRoleType type); + Task AddDesignatedRoleAsync(ulong guildId, ulong roleId, DesignatedRoleType type); /// /// Unassigns a role's previously given designation. @@ -95,7 +95,7 @@ public DesignatedRoleService(IAuthorizationService authorizationService, IDesign } /// - public async Task AddDesignatedRoleAsync(ulong guildId, ulong roleId, DesignatedRoleType type) + public async Task AddDesignatedRoleAsync(ulong guildId, ulong roleId, DesignatedRoleType type) { AuthorizationService.RequireAuthenticatedUser(); AuthorizationService.RequireClaims(AuthorizationClaim.DesignatedRoleMappingCreate); @@ -111,7 +111,7 @@ public async Task AddDesignatedRoleAsync(ulong guildId, ulong roleId, Designated }, default)) throw new InvalidOperationException($"Role {roleId} already has a {type} designation"); - await DesignatedRoleMappingRepository.CreateAsync(new DesignatedRoleMappingCreationData() + var entityId = await DesignatedRoleMappingRepository.CreateAsync(new DesignatedRoleMappingCreationData() { GuildId = guildId, RoleId = roleId, @@ -120,6 +120,8 @@ await DesignatedRoleMappingRepository.CreateAsync(new DesignatedRoleMappingCreat }); transaction.Commit(); + + return entityId; } } diff --git a/Modix.Services/Promotions/PromotionsService.cs b/Modix.Services/Promotions/PromotionsService.cs index 4af6c2796..b0f45dff9 100644 --- a/Modix.Services/Promotions/PromotionsService.cs +++ b/Modix.Services/Promotions/PromotionsService.cs @@ -50,7 +50,7 @@ public interface IPromotionsService /// The value to use for the new comment. /// The value to use for the new comment. /// A that will complete when the operation has completed. - Task AddCommentAsync(long campaignId, PromotionSentiment sentiment, string? content); + Task AddCommentAsync(long campaignId, PromotionSentiment sentiment, string? content); /// /// Updates an existing comment on a promotion campaign by deleting the comment and adding a new one. @@ -59,7 +59,7 @@ public interface IPromotionsService /// The value of the updated comment. /// The value of the updated comment. /// A that will complete when the operation has completed. - Task UpdateCommentAsync(long commentId, PromotionSentiment newSentiment, string? newContent); + Task UpdateCommentAsync(long commentId, PromotionSentiment newSentiment, string? newContent); Task AddOrUpdateCommentAsync(long campaignId, Optional sentiment, Optional comment = default); @@ -187,7 +187,7 @@ public async Task CreateCampaignAsync(ulong subjectId, string? comment = null) } /// - public async Task AddCommentAsync(long campaignId, PromotionSentiment sentiment, string? content) + public async Task AddCommentAsync(long campaignId, PromotionSentiment sentiment, string? content) { AuthorizationService.RequireAuthenticatedGuild(); AuthorizationService.RequireAuthenticatedUser(); @@ -234,10 +234,12 @@ public async Task AddCommentAsync(long campaignId, PromotionSentiment sentiment, } PublishActionNotificationAsync(resultAction); + + return resultAction; } /// - public async Task UpdateCommentAsync(long commentId, PromotionSentiment newSentiment, string? newContent) + public async Task UpdateCommentAsync(long commentId, PromotionSentiment newSentiment, string? newContent) { AuthorizationService.RequireAuthenticatedUser(); AuthorizationService.RequireClaims(AuthorizationClaim.PromotionsComment); @@ -263,6 +265,8 @@ public async Task UpdateCommentAsync(long commentId, PromotionSentiment newSenti } PublishActionNotificationAsync(resultAction); + + return resultAction; } public async Task AddOrUpdateCommentAsync(long campaignId, Optional sentiment, Optional content = default) diff --git a/Modix.Services/Utilities/DiscordWebhookSink.cs b/Modix.Services/Utilities/DiscordWebhookSink.cs index 3633d917a..4aeaf9e4c 100644 --- a/Modix.Services/Utilities/DiscordWebhookSink.cs +++ b/Modix.Services/Utilities/DiscordWebhookSink.cs @@ -1,12 +1,12 @@ using System; -using Serilog; -using Serilog.Core; -using Serilog.Events; -using Serilog.Configuration; -using Discord.Webhook; using Discord; +using Discord.Webhook; using Modix.Services.CodePaste; using Newtonsoft.Json; +using Serilog; +using Serilog.Configuration; +using Serilog.Core; +using Serilog.Events; namespace Modix.Services.Utilities { @@ -14,7 +14,7 @@ public sealed class DiscordWebhookSink : ILogEventSink, IDisposable { - private readonly CodePasteService _codePasteService; + private readonly Lazy _codePasteService; private readonly DiscordWebhookClient _discordWebhookClient; private readonly IFormatProvider _formatProvider; private readonly JsonSerializerSettings _jsonSerializerSettings; @@ -22,7 +22,7 @@ public DiscordWebhookSink( ulong webhookId, string webhookToken, IFormatProvider formatProvider, - CodePasteService codePasteService) + Lazy codePasteService) { _codePasteService = codePasteService; _discordWebhookClient = new DiscordWebhookClient(webhookId, webhookToken); @@ -58,7 +58,7 @@ public void Emit(LogEvent logEvent) var eventAsJson = JsonConvert.SerializeObject(logEvent, _jsonSerializerSettings); - var url = _codePasteService.UploadCodeAsync(eventAsJson).GetAwaiter().GetResult(); + var url = _codePasteService.Value.UploadCodeAsync(eventAsJson).GetAwaiter().GetResult(); message.AddField(new EmbedFieldBuilder() .WithIsInline(false) @@ -90,7 +90,7 @@ public void Dispose() public static class DiscordWebhookSinkExtensions { - public static LoggerConfiguration DiscordWebhookSink(this LoggerSinkConfiguration config, ulong id, string token, LogEventLevel minLevel, CodePasteService codePasteService) + public static LoggerConfiguration DiscordWebhookSink(this LoggerSinkConfiguration config, ulong id, string token, LogEventLevel minLevel, Lazy codePasteService) { return config.Sink(new DiscordWebhookSink(id, token, null, codePasteService), minLevel); } diff --git a/Modix.Web/App.razor b/Modix.Web/App.razor new file mode 100644 index 000000000..a0aa08a85 --- /dev/null +++ b/Modix.Web/App.razor @@ -0,0 +1,79 @@ +@using Discord.WebSocket; +@using Modix.Web.Models; +@using Modix.Web.Services; +@using MudBlazor +@using System.Security.Claims; +@using Modix.Services.Core; + + + + + + + Sorry, you don't have access to that page. + + + Please wait... + + + + + + Not found + + Sorry, there's nothing at this address. + + + + + +@code { + [Parameter] + public string? SelectedGuild { get; set; } + + [Parameter] + public string? ShowInfractionState { get; set; } + + [Parameter] + public string? ShowDeletedInfractions { get; set; } + + [Parameter] + public string? ShowInactivePromotions { get; set; } + + [Inject] + public SessionState SessionState { get; set; } = null!; + + [Inject] + public DiscordHelper DiscordHelper { get; set; } = null!; + + [Inject] + public AuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!; + + [Inject] + public Modix.Services.Core.IAuthorizationService AuthorizationService { get; set; } = null!; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + if (!authState.User.Identity?.IsAuthenticated ?? false) + return; + + var userId = authState.User.FindFirst(x => x.Type == ClaimTypes.NameIdentifier)?.Value; + + _ = ulong.TryParse(userId, out var userSnowflake); + _ = ulong.TryParse(SelectedGuild, out var selectedGuildId); + _ = bool.TryParse(ShowInfractionState, out var showInfractionState); + _ = bool.TryParse(ShowDeletedInfractions, out var showDeletedInfractions); + _ = bool.TryParse(ShowInactivePromotions, out var showInactivePromotions); + + SessionState.CurrentUserId = userSnowflake; + SessionState.SelectedGuild = selectedGuildId; + SessionState.ShowInfractionState = showInfractionState; + SessionState.ShowDeletedInfractions = showDeletedInfractions; + SessionState.ShowInactivePromotions = showInactivePromotions; + + var currentUser = DiscordHelper.GetCurrentUser(); + + await AuthorizationService.OnAuthenticatedAsync(currentUser!.Id, currentUser.Guild.Id, currentUser.Roles.Select(x => x.Id).ToList()); + } +} \ No newline at end of file diff --git a/Modix.Web/Components/AnchorNavigation.razor b/Modix.Web/Components/AnchorNavigation.razor new file mode 100644 index 000000000..db896e534 --- /dev/null +++ b/Modix.Web/Components/AnchorNavigation.razor @@ -0,0 +1,39 @@ +@inject IJSRuntime JSRuntime +@inject NavigationManager NavigationManager +@implements IDisposable + +@code { + protected override void OnInitialized() + { + NavigationManager.LocationChanged += OnLocationChanged; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await ScrollToFragment(); + } + + public void Dispose() + { + NavigationManager.LocationChanged -= OnLocationChanged; + } + + private async void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + await ScrollToFragment(); + } + + private async Task ScrollToFragment() + { + var uri = new Uri(NavigationManager.Uri, UriKind.Absolute); + var fragment = uri.Fragment; + if (fragment.StartsWith('#')) + { + var elementId = fragment[1..]; + if (!string.IsNullOrEmpty(elementId)) + { + await JSRuntime.InvokeVoidAsync("scrollToElementId", elementId); + } + } + } +} \ No newline at end of file diff --git a/Modix.Web/Components/AutoComplete.razor b/Modix.Web/Components/AutoComplete.razor new file mode 100644 index 000000000..ae430f952 --- /dev/null +++ b/Modix.Web/Components/AutoComplete.razor @@ -0,0 +1,24 @@ +@using Modix.Web.Models; +@using Modix.Web.Services; +@using MudBlazor + +@typeparam T + +@if (Title is not null) +{ + @Title +} + + diff --git a/Modix.Web/Components/AutoComplete.razor.cs b/Modix.Web/Components/AutoComplete.razor.cs new file mode 100644 index 000000000..e9d40f651 --- /dev/null +++ b/Modix.Web/Components/AutoComplete.razor.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Components; +using Modix.Web.Models.Common; + +namespace Modix.Web.Components; + +public partial class AutoComplete where T : IAutoCompleteItem +{ + [Parameter] + // Nullability mismatch between MudBlazor, this value is checked for null in the MudBlazor component and changes behavior based on that + // This is to get around the annoying warning when we assign a 'RenderFragment?' to 'RenderFragment' + // In theory the nullable variant is "more" correct, but alas, here we are + public RenderFragment ItemTemplate { get; set; } = null!; + + [Parameter] + public string? Placeholder { get; set; } + + [Parameter] + public EventCallback SelectedItemChanged { get; set; } + + [Parameter] + public string? Title { get; set; } + + [Parameter, EditorRequired] + public Func>> SearchFunc { get; set; } = null!; +} diff --git a/Modix.Web/Components/Configuration/Channels.razor b/Modix.Web/Components/Configuration/Channels.razor new file mode 100644 index 000000000..2a57d855b --- /dev/null +++ b/Modix.Web/Components/Configuration/Channels.razor @@ -0,0 +1,171 @@ +@using Modix.Data.Models.Core; +@using Modix.Services.Core; +@using Modix.Web.Models.Common; +@using Modix.Web.Models.Configuration; +@using Modix.Web.Models.UserLookup; +@using Modix.Web.Services; +@using MudBlazor +@using Humanizer; +@using System.Security.Claims; + +Modix - Channels +Channel Designations + + + @if (DesignatedChannelMappings is not null && DesignatedChannelTypes is not null) + { + + + Assign a Channel + + + + Designation + + @foreach (var designation in DesignatedChannelTypes) + { + + } + + + + + Assign + + + Cancel + + + + + + @foreach (var designatedChannelType in DesignatedChannelTypes.OrderBy(x => x.ToString())) + { + +
+
+ + @designatedChannelType.ToString().Titleize() + + @if (!DesignatedChannelMappings.TryGetValue(designatedChannelType, out var channelDesignations) || !channelDesignations.Any()) + { + + NONE ASSIGNED + + } + else + { + @foreach (var designatedChannelMapping in channelDesignations) + { + + } + } +
+ +
+ + + +
+
+
+ + } +
+
+ } +
+ +@code { + [Inject] + public DiscordHelper DiscordHelper { get; set; } = null!; + + [Inject] + public IDesignatedChannelService DesignatedChannelService { get; set; } = null!; + + [Inject] + public ISnackbar Snackbar { get; set; } = null!; + + private Dictionary>? DesignatedChannelMappings { get; set; } + private DesignatedChannelType[]? DesignatedChannelTypes { get; set; } + + private bool _createDialogVisible; + private DesignatedChannelType? _selectedDesignatedChannelType; + private ChannelInformation? _selectedChannel; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + var currentGuild = DiscordHelper.GetUserGuild(); + var designatedChannels = await DesignatedChannelService.GetDesignatedChannelsAsync(currentGuild.Id); + + DesignatedChannelMappings = designatedChannels + .Select(d => new DesignatedChannelData(d.Id, d.Channel.Id, d.Type, currentGuild?.GetChannel(d.Channel.Id)?.Name ?? d.Channel.Name)) + .ToLookup(x => x.ChannelDesignation, x => x) + .ToDictionary(x => x.Key, x => x.ToList()); + + DesignatedChannelTypes = Enum.GetValues(); + + StateHasChanged(); + } + + public void ToggleCreateDialog() + { + _createDialogVisible = !_createDialogVisible; + if (_createDialogVisible) + { + _selectedChannel = null; + _selectedDesignatedChannelType = null; + } + } + + private void SelectedChannelChanged(ChannelInformation channel) + { + _selectedChannel = channel; + } + + public async Task SaveDesignation() + { + var currentGuild = DiscordHelper.GetUserGuild(); + var channel = (Discord.IMessageChannel)currentGuild.GetChannel(_selectedChannel!.Id); + + var id = await DesignatedChannelService.AddDesignatedChannelAsync(currentGuild, channel, _selectedDesignatedChannelType!.Value); + + _createDialogVisible = false; + + if (!DesignatedChannelMappings!.ContainsKey(_selectedDesignatedChannelType.Value)) + { + DesignatedChannelMappings[_selectedDesignatedChannelType.Value] = new List(); + } + + DesignatedChannelMappings[_selectedDesignatedChannelType.Value].Add(new DesignatedChannelData(id, _selectedChannel.Id, _selectedDesignatedChannelType.Value, _selectedChannel.Name)); + + Snackbar.Add($"Added designation '{_selectedDesignatedChannelType}' to channel '{_selectedChannel.Name}'", Severity.Success); + } + + public async Task RemoveDesignation(long id, DesignatedChannelType designatedChannelType) + { + await DesignatedChannelService.RemoveDesignatedChannelByIdAsync(id); + + var channelMappingsWithType = DesignatedChannelMappings![designatedChannelType]; + var removedChannelMapping = channelMappingsWithType.First(x => x.Id == id); + + channelMappingsWithType.Remove(removedChannelMapping); + + Snackbar.Add($"Removed designation '{designatedChannelType}' from channel '{removedChannelMapping.Name}'", Severity.Success); + } +} diff --git a/Modix.Web/Components/Configuration/Claims.razor b/Modix.Web/Components/Configuration/Claims.razor new file mode 100644 index 000000000..c7192a595 --- /dev/null +++ b/Modix.Web/Components/Configuration/Claims.razor @@ -0,0 +1,166 @@ +@using Modix.Data.Models.Core; +@using Modix.Data.Repositories; +@using Modix.Data.Utilities; +@using Modix.Models.Core; +@using Modix.Web.Models.UserLookup; +@using Modix.Web.Services; +@using MudBlazor +@using System.Reflection; +@using Humanizer; +@using Modix.Web.Models.Common; + +Modix - Claims + +@if (ClaimData is not null && MappedClaims is not null && Roles is not null && _selectedRole is not null) +{ +
+ Claim Assignments +
+
+ + + @foreach (var role in Roles.Values) + { + + + } + + +
+
+ @foreach (var claimData in ClaimData.OrderBy(x => x.Value.Name).GroupBy(x => x.Value.Category)) + { + @claimData.Key.ToString().Titleize() + foreach (var groupedClaimData in claimData) + { + MappedClaims.TryGetValue((_selectedRole, groupedClaimData.Key), out var mappedClaimForRole); + + +
+ + @groupedClaimData.Value.Name.Titleize() + + + + + X + + + + – + + + + ✓ + + +
+ @groupedClaimData.Value.Description + +
+ } + } +
+
+
+} + +@code { + [Inject] + public IClaimMappingRepository ClaimMappingRepository { get; set; } = null!; + + [Inject] + public DiscordHelper DiscordHelper { get; set; } = null!; + + [Inject] + public Modix.Services.Core.IAuthorizationService AuthorizationService { get; set; } = null!; + + [Inject] + public ISnackbar Snackbar { get; set; } = null!; + + private Dictionary? ClaimData { get; set; } + private Dictionary<(ulong?, AuthorizationClaim), ClaimMappingBrief>? MappedClaims { get; set; } + private Dictionary? Roles { get; set; } + + private ulong? _selectedRole; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + ClaimData = typeof(AuthorizationClaim).GetFields(BindingFlags.Public | BindingFlags.Static).ToDictionary + ( + d => (AuthorizationClaim)d.GetValue(null)!, + d => + { + var claimInfo = (ClaimInfoAttribute)d.GetCustomAttributes(typeof(ClaimInfoAttribute), true).First()!; + + return new ClaimInfoData + { + Name = d.Name, + Description = claimInfo.Description, + Category = claimInfo.Category + }; + } + ); + + var currentGuild = DiscordHelper.GetUserGuild(); + var mappedClaims = await ClaimMappingRepository.SearchBriefsAsync(new ClaimMappingSearchCriteria + { + IsDeleted = false, + GuildId = currentGuild.Id + }); + + MappedClaims = mappedClaims.ToDictionary(x => (x.RoleId, x.Claim), x => x); + + Roles = currentGuild.Roles + .Select(d => new RoleInformation(d.Id, d.Name, d.Color.ToString())) + .OrderBy(x => x.Name) + .ToDictionary(x => x.Id, x => x); + + _selectedRole = Roles.First().Key; + + StateHasChanged(); + } + + private async Task ModifyMapping(ulong roleId, AuthorizationClaim authorizationClaim, ClaimMappingType? claimMappingType) + { + var key = (roleId, authorizationClaim); + if (MappedClaims!.TryGetValue(key, out var claimMapping) && claimMapping.Type == claimMappingType) + return; + + await AuthorizationService.ModifyClaimMappingAsync(roleId, authorizationClaim, claimMappingType); + if (claimMappingType is ClaimMappingType.Denied or ClaimMappingType.Granted) + { + Snackbar.Add($"Claim '{authorizationClaim}' for '{Roles![roleId].Name}' was changed to '{claimMappingType}'", Severity.Success); + if (claimMapping is null) + { + MappedClaims[key] = new ClaimMappingBrief + { + Claim = authorizationClaim, + RoleId = roleId, + Type = claimMappingType.Value + }; + } + else + { + claimMapping.Type = claimMappingType.Value; + } + } + else + { + Snackbar.Add($"Claim '{authorizationClaim}' for '{Roles![roleId].Name}' was removed.", Severity.Success); + MappedClaims.Remove(key); + } + } +} diff --git a/Modix.Web/Components/Configuration/IndividualDesignation.razor b/Modix.Web/Components/Configuration/IndividualDesignation.razor new file mode 100644 index 000000000..f79267fa0 --- /dev/null +++ b/Modix.Web/Components/Configuration/IndividualDesignation.razor @@ -0,0 +1,66 @@ +@using Modix.Data.Models.Core; +@using Modix.Web.Models.Configuration; +@using MudBlazor + + + + + @NamePrefix@Name + + + @if (!_showConfirm) + { + + X + + } + else + { + Remove Designation? + + + Yes + + + No + + } + + + + +@code { + [Parameter, EditorRequired] + public EventCallback RemoveDesignation { get; set; } + + [Parameter, EditorRequired] + public string? AuthorizationRoleForDelete { get; set; } + + [Parameter, EditorRequired] + public string? NamePrefix { get; set; } + + [Parameter, EditorRequired] + public string? Name { get; set; } + + [Parameter, EditorRequired] + public long Id { get; set; } + + private bool _showConfirm; +} diff --git a/Modix.Web/Components/Configuration/Roles.razor b/Modix.Web/Components/Configuration/Roles.razor new file mode 100644 index 000000000..b7241a06e --- /dev/null +++ b/Modix.Web/Components/Configuration/Roles.razor @@ -0,0 +1,171 @@ +@using Modix.Data.Models.Core; +@using Modix.Services.Core; +@using Modix.Web.Models.Configuration; +@using Modix.Web.Models.UserLookup; +@using Modix.Web.Services; +@using MudBlazor +@using Humanizer; +@using System.Security.Claims; +@using Modix.Web.Models.Common; + +Modix - Roles +Role Designations + + + @if (DesignatedRoleMappings is not null && DesignatedRoleTypes is not null) + { + + + Assign a Role + + + + + Designation + + @foreach (var designation in DesignatedRoleTypes) + { + + } + + + + + Assign + + + Cancel + + + + + + @foreach (var designatedRoleType in DesignatedRoleTypes.OrderBy(x => x.ToString())) + { + +
+
+ + @designatedRoleType.ToString().Titleize() + + @if (!DesignatedRoleMappings.TryGetValue(designatedRoleType, out var roleDesignations) || !roleDesignations.Any()) + { + + NONE ASSIGNED + + } + else + { + @foreach (var designatedRoleMapping in roleDesignations) + { + + } + } +
+ +
+ + + +
+
+
+ + } +
+
+ } +
+ +@code { + [Inject] + public DiscordHelper DiscordHelper { get; set; } = null!; + + [Inject] + public IDesignatedRoleService DesignatedRoleService { get; set; } = null!; + + [Inject] + public ISnackbar Snackbar { get; set; } = null!; + + private Dictionary>? DesignatedRoleMappings { get; set; } + private DesignatedRoleType[]? DesignatedRoleTypes { get; set; } + + private bool _createDialogVisible; + private DesignatedRoleType? _selectedDesignatedRoleType; + private RoleInformation? _selectedRole; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + var currentGuild = DiscordHelper.GetUserGuild(); + var designatedRoles = await DesignatedRoleService.GetDesignatedRolesAsync(currentGuild.Id); + + DesignatedRoleMappings = designatedRoles + .Select(d => new DesignatedRoleData(d.Id, d.Role.Id, d.Type, currentGuild?.GetRole(d.Role.Id)?.Name ?? d.Role.Name)) + .ToLookup(x => x.RoleDesignation, x => x) + .ToDictionary(x => x.Key, x => x.ToList()); + + DesignatedRoleTypes = Enum.GetValues(); + + StateHasChanged(); + } + + public void ToggleCreateDialog() + { + _createDialogVisible = !_createDialogVisible; + if (_createDialogVisible) + { + _selectedRole = null; + _selectedDesignatedRoleType = null; + } + } + + private void SelectedRoleChanged(RoleInformation role) + { + _selectedRole = role; + } + + public async Task SaveDesignation() + { + var currentGuild = DiscordHelper.GetUserGuild(); + + var id = await DesignatedRoleService.AddDesignatedRoleAsync(currentGuild.Id, _selectedRole!.Id, _selectedDesignatedRoleType!.Value); + + _createDialogVisible = false; + + if (!DesignatedRoleMappings!.ContainsKey(_selectedDesignatedRoleType.Value)) + { + DesignatedRoleMappings[_selectedDesignatedRoleType.Value] = new List(); + } + + DesignatedRoleMappings[_selectedDesignatedRoleType.Value].Add(new DesignatedRoleData(id, _selectedRole.Id, _selectedDesignatedRoleType.Value, _selectedRole.Name)); + + Snackbar.Add($"Added designation '{_selectedDesignatedRoleType}' to role '{_selectedRole.Name}'", Severity.Success); + } + + public async Task RemoveDesignation(long id, DesignatedRoleType designatedRoleType) + { + await DesignatedRoleService.RemoveDesignatedRoleByIdAsync(id); + + var roleMappingsWithType = DesignatedRoleMappings![designatedRoleType]; + var removedRoleMapping = roleMappingsWithType.First(x => x.Id == id); + + roleMappingsWithType.Remove(removedRoleMapping); + + Snackbar.Add($"Removed designation '{designatedRoleType}' from role '{removedRoleMapping.Name}'", Severity.Success); + } +} diff --git a/Modix.Web/Components/ConfirmationDialog.razor b/Modix.Web/Components/ConfirmationDialog.razor new file mode 100644 index 000000000..4fd7aba01 --- /dev/null +++ b/Modix.Web/Components/ConfirmationDialog.razor @@ -0,0 +1,29 @@ +@using Modix.Data.Models.Promotions; +@using MudBlazor + + + + Confirmation + + + @Content + + + Confirm + + Cancel + + + + + +@code { + [CascadingParameter] + MudDialogInstance? MudDialog { get; set; } + + [Parameter] + public required string Content { get; set; } + + void Submit() => MudDialog?.Close(); + void Cancel() => MudDialog?.Cancel(); +} diff --git a/Modix.Web/Components/CreateCampaignComment.razor b/Modix.Web/Components/CreateCampaignComment.razor new file mode 100644 index 000000000..1d520e2cd --- /dev/null +++ b/Modix.Web/Components/CreateCampaignComment.razor @@ -0,0 +1,40 @@ +@using Modix.Data.Models.Promotions; +@using MudBlazor; + +
+ +
+ + + +
+
+ + + + + + + + +
+ + + Create +
+
+ +@code { + + [Parameter, EditorRequired] + public EventCallback<(PromotionSentiment PromotionSentiment, string? Content)> OnCampaignCommentCreation { get; set; } + + private PromotionSentiment PromotionSentiment { get; set; } = PromotionSentiment.Approve; + private string? Content { get; set; } + + private async Task Submit() + { + await OnCampaignCommentCreation.InvokeAsync((PromotionSentiment, Content)); + } + +} diff --git a/Modix.Web/Components/DeletedMessages.razor b/Modix.Web/Components/DeletedMessages.razor new file mode 100644 index 000000000..21392c0ba --- /dev/null +++ b/Modix.Web/Components/DeletedMessages.razor @@ -0,0 +1,281 @@ +@using Discord.WebSocket; +@using Discord; +@using Modix.Data.Models.Moderation; +@using Modix.Data.Models; +@using Modix.Services.Moderation; +@using Modix.Web.Models.DeletedMessages; +@using Modix.Web.Services; +@using MudBlazor +@using Modix.Services.Utilities + +Modix - Deletions + + + +
+ Batch Deletion Context + + +
+
+ + @if (!DeletedMessagesContext.TryGetValue(_currentContext, out var deletedMessageContext)) + { +
+ +
+ } + else if (!deletedMessageContext.Any()) + { + No messages + } + else + { + + Starting + @deletedMessageContext.First().SentTime?.ToLocalTime().ToString("MM/dd/yy, h:mm:ss tt") + + + + + @foreach (var item in deletedMessageContext) + { + var wasDeleted = item.SentTime is null; + var styling = wasDeleted ? "background-color: #f5f5f5; border-top: 1px solid #fff" : ""; + var title = wasDeleted ? "This was deleted" : item.SentTime!.Value.ToLocalTime().ToString(); + +
+
+ @if (wasDeleted) + { + 🚫 + } + else + { + @item.SentTime!.Value.ToLocalTime().ToString("hh:mm") + } + + @item.Username +
+ @if (string.IsNullOrWhiteSpace(item.Content)) + { + No Content + } + else + { + + } +
+ } + } + +
+
+ + + + + Refresh + + + + Channel + + + + Author + + + + Deleted On + + + Deleted By + + + + Content + + + + Reason + + + + Batch ID + + + Actions + + + #@deletedMessage.Channel.Name + @deletedMessage.Author.GetFullUsername() + @deletedMessage.Created + @deletedMessage.CreatedBy.GetFullUsername() + + + + @deletedMessage.Reason + @deletedMessage.BatchId + + Context + + + + + + + + + + + +@code { + + [Inject] + public IModerationService ModerationService { get; set; } = null!; + + [Inject] + public DiscordHelper DiscordHelper { get; set; } = null!; + + [Inject] + public ISnackbar Snackbar { get; set; } = null!; + + private MudTable? TableRef; + private Dictionary> DeletedMessagesContext { get; } = new Dictionary>(); + private bool _deletedMessagesContextDialogVisible; + private long _currentContext; + + private TableFilter _tableFilter = new(); + + private async Task RefreshTable() + { + if (TableRef is null) + return; + + await TableRef.ReloadServerData(); + } + + private async Task FilterChanged(Action filterSetter) + { + filterSetter(); + await RefreshTable(); + } + + private async Task OpenDialog(long? batchId) + { + if (batchId is null) + return; + + _currentContext = batchId.Value; + _deletedMessagesContextDialogVisible = true; + + await GetDeletionContext(_currentContext); + } + + private void CloseDialog() => _deletedMessagesContextDialogVisible = false; + + private async Task> LoadDeletedMessages(TableState tableState) + { + var currentGuild = DiscordHelper.GetUserGuild(); + + var searchCriteria = new DeletedMessageSearchCriteria + { + GuildId = currentGuild.Id, + Channel = _tableFilter.Channel, + ChannelId = _tableFilter.ChannelId, + Author = _tableFilter.Author, + AuthorId = _tableFilter.AuthorId, + CreatedBy = _tableFilter.CreatedBy, + CreatedById = _tableFilter.CreatedById, + Content = _tableFilter.Content, + Reason = _tableFilter.Reason, + BatchId = _tableFilter.BatchId + }; + + var result = await ModerationService.SearchDeletedMessagesAsync(searchCriteria, + new[] + { + new SortingCriteria + { + PropertyName = tableState.SortLabel ?? nameof(DeletedMessageSummary.Created), + Direction = tableState.SortDirection == MudBlazor.SortDirection.Ascending + ? Data.Models.SortDirection.Ascending + : Data.Models.SortDirection.Descending + } + }, + new PagingCriteria + { + FirstRecordIndex = tableState.Page * tableState.PageSize, + PageSize = tableState.PageSize, + } + ); + + return new TableData + { + TotalItems = (int)result.FilteredRecordCount, + Items = result.Records + }; + } + + private async Task GetDeletionContext(long batchId) + { + _currentContext = batchId; + + if (DeletedMessagesContext.ContainsKey(batchId)) + return; + + var deletedMessages = await ModerationService.SearchDeletedMessagesAsync( + new DeletedMessageSearchCriteria + { + BatchId = batchId + }, + new SortingCriteria[] + { + //Sort ascending, so the earliest message is first + new SortingCriteria { PropertyName = nameof(DeletedMessageSummary.MessageId), Direction = Data.Models.SortDirection.Ascending } + }, + new PagingCriteria() + ); + + var firstMessage = deletedMessages.Records.FirstOrDefault(); + + if (firstMessage is null) + { + CloseDialog(); + Snackbar.Add($"Couldn't find messages for batch id {batchId}", Severity.Error); + return; + } + + var currentUser = DiscordHelper.GetCurrentUser(); + var batchChannelId = deletedMessages.Records.First().Channel.Id; + if (currentUser!.Guild.GetChannel(batchChannelId) is not ISocketMessageChannel foundChannel) + { + CloseDialog(); + Snackbar.Add($"Couldn't recreate context - text channel with id {batchChannelId} not found", Severity.Error); + return; + } + + if (currentUser.GetPermissions(foundChannel as IGuildChannel).ReadMessageHistory == false) + { + CloseDialog(); + Snackbar.Add($"You don't have read permissions for the channel this batch was deleted in (#{foundChannel.Name})", Severity.Error); + return; + } + + var beforeMessages = await foundChannel.GetMessagesAsync(firstMessage.MessageId, Discord.Direction.Before, 25).FlattenAsync(); + var afterMessages = await foundChannel.GetMessagesAsync(firstMessage.MessageId, Discord.Direction.After, 25 + (int)deletedMessages.FilteredRecordCount).FlattenAsync(); + + var allMessages = new List(); + allMessages.AddRange(deletedMessages.Records.Select(d => new DeletedMessageInformation(d.MessageId, null, null, d.Author.GetFullUsername(), d.Content))); + allMessages.AddRange(beforeMessages.Select(d => DeletedMessageInformation.FromIMessage(d))); + allMessages.AddRange(afterMessages.Select(d => DeletedMessageInformation.FromIMessage(d))); + + DeletedMessagesContext[batchId] = allMessages.OrderBy(d => d.MessageId).ToList(); + } + +} diff --git a/Modix.Web/Components/EditPromotionCommentDialog.razor b/Modix.Web/Components/EditPromotionCommentDialog.razor new file mode 100644 index 000000000..33a7f599f --- /dev/null +++ b/Modix.Web/Components/EditPromotionCommentDialog.razor @@ -0,0 +1,42 @@ +@using Modix.Data.Models.Promotions; +@using MudBlazor + + + + Edit Comment + + +
+ + + + + + + + + +
+
+ + Update + + Cancel + +
+ + + +@code { + [CascadingParameter] + MudDialogInstance? MudDialog { get; set; } + + [Parameter] + public PromotionSentiment PromotionSentiment { get; set; } + + [Parameter] + public required string Content { get; set; } + + void Submit() => MudDialog?.Close(DialogResult.Ok((PromotionSentiment, Content))); + void Cancel() => MudDialog?.Cancel(); +} diff --git a/Modix.Web/Components/Infractions.razor b/Modix.Web/Components/Infractions.razor new file mode 100644 index 000000000..abd1102b2 --- /dev/null +++ b/Modix.Web/Components/Infractions.razor @@ -0,0 +1,455 @@ +@using Modix.Data.Models; +@using Modix.Data.Models.Core; +@using Modix.Data.Models.Moderation; +@using Modix.Services.Moderation; +@using Modix.Web.Models; +@using Modix.Web.Models.Infractions; +@using Modix.Web.Services; +@using MudBlazor; +@using System.Security.Claims; +@using Modix.Web.Models.Common; + +Modix - Infractions + + + + + + Create Infraction + + + + + + @user.Name + + + + Infraction +
+
+ + + + + + + + + + + + + + +
+ +
+ + @if (_infractionType == InfractionType.Mute) + { + Duration +
+ + + + + +
+ } +
+ + + Save + + + Cancel + +
+ + + + +
+
+ Create + Refresh +
+ +
+ + +
+
+ + + + + Id + + + + Type + + @foreach (var infractionType in Enum.GetValues()) + { + + } + + + + Created On + + + Subject + + + + Creator + + + Reason + @if (_showState) + { + State + } + @if (_canDeleteInfractions || _canRescind) + { + Actions + } + + + @infraction.Id + @infraction.Type + @infraction.CreateAction.Created.ToString("MM/dd/yy, h:mm:ss tt") + @(GetUsername(infraction.Subject)) + @(GetUsername(infraction.CreateAction.CreatedBy)) + @infraction.Reason + @if (_showState) + { + @(infraction.RescindAction != null ? "Rescinded" : infraction.DeleteAction != null ? "Deleted" : "Active") + } + @if (_canDeleteInfractions || _canRescind) + { + +
+ @if (infraction.CanBeDeleted) + { + Delete + } + @if (infraction.CanBeRescind) + { + Rescind + } +
+
+ } +
+ + + +
+
+
+@code { + + [Parameter] + public string? Subject { get; set; } + + [Parameter] + public string? Id { get; set; } + + [Inject] + public IModerationService ModerationService { get; set; } = null!; + + [Inject] + public DiscordHelper DiscordHelper { get; set; } = null!; + + [Inject] + public ISnackbar Snackbar { get; set; } = null!; + + [Inject] + public IDialogService DialogService { get; set; } = null!; + + [Inject] + public SessionState SessionState { get; set; } = null!; + + [Inject] + public CookieService CookieService { get; set; } = null!; + + [CascadingParameter] + private Task? AuthState { get; set; } + + private MudTable? TableRef; + + private bool _showState; + + private bool _canRescind; + private bool _canDeleteInfractions; + + private ModixUser? _selectedUser; + private InfractionType _infractionType = InfractionType.Notice; + private string? _infractionReason; + private bool _createDialogVisible; + private int? _newInfractionMonths; + private int? _newInfractionDays; + private int? _newInfractionHours; + private int? _newInfractionMinutes; + private int? _newInfractionSeconds; + + private TableFilter _tableFilter = new(); + + protected override void OnInitialized() + { + _tableFilter.ShowDeleted = SessionState.ShowDeletedInfractions; + _showState = SessionState.ShowInfractionState; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + if (AuthState is null) + return; + + var auth = await AuthState; + _canRescind = auth.User.HasClaim(ClaimTypes.Role, nameof(AuthorizationClaim.ModerationRescind)); + _canDeleteInfractions = auth.User.HasClaim(ClaimTypes.Role, nameof(AuthorizationClaim.ModerationDeleteInfraction)); + } + + private async Task ShowStateChanged(bool showState) + { + _showState = showState; + await CookieService.SetShowInfractionStateAsync(showState); + } + + private async Task ShowDeletedChanged(bool showDeleted) + { + await FilterChanged(() => _tableFilter.ShowDeleted = showDeleted); + await CookieService.SetShowDeletedInfractionsAsync(showDeleted); + } + + private void ToggleDialog() + { + _createDialogVisible = !_createDialogVisible; + } + + private void SelectedUserChanged(ModixUser user) + { + _selectedUser = user; + } + + private async Task FilterChanged(Action filterSetter) + { + filterSetter(); + await RefreshTable(); + } + + private static string GetUsername(GuildUserBrief userBrief) + { + return $"{userBrief.Username}{(userBrief.Discriminator == "0000" ? "" : "#" + userBrief.Discriminator)}"; + } + + private async Task RescindInfraction(InfractionData infraction) + { + try + { + var dialogParams = new DialogParameters + { + { x => x.Content, $"Are you sure you want to rescind infraction #{infraction.Id}?"} + }; + + var dialog = DialogService.Show("", dialogParams); + var result = await dialog.Result; + + if (result.Canceled) + { + Snackbar.Add("Action was cancelled", Severity.Info); + return; + } + + await ModerationService.RescindInfractionAsync(infraction.Id); + await RefreshTable(); + } + catch (Exception ex) + { + Snackbar.Add(ex.Message, Severity.Error); + } + } + + private async Task DeleteInfraction(InfractionData infraction) + { + try + { + var dialogParams = new DialogParameters + { + { x => x.Content, $"Are you sure you want to delete infraction #{infraction.Id}?"} + }; + + var dialog = DialogService.Show("", dialogParams); + var result = await dialog.Result; + + if (result.Canceled) + { + Snackbar.Add("Action was cancelled", Severity.Info); + return; + } + + await ModerationService.DeleteInfractionAsync(infraction.Id); + await RefreshTable(); + } + catch (Exception ex) + { + Snackbar.Add(ex.Message, Severity.Error); + } + } + + private async Task SaveInfraction() + { + _createDialogVisible = false; + + var duration = GetTimeSpan( + _newInfractionMonths, + _newInfractionDays, + _newInfractionHours, + _newInfractionMinutes, + _newInfractionSeconds); + + try + { + var currentUser = DiscordHelper.GetCurrentUser(); + await ModerationService.CreateInfractionAsync(currentUser!.Guild.Id, currentUser.Id, _infractionType, _selectedUser!.UserId, _infractionReason!, duration); + } + catch (InvalidOperationException ex) + { + Snackbar.Add(ex.Message, Severity.Error); + return; + } + + + Snackbar.Add($"Added infraction for user {_selectedUser!.Name}", Severity.Success); + + _selectedUser = null; + _newInfractionMonths = null; + _newInfractionDays = null; + _newInfractionHours = null; + _newInfractionMinutes = null; + _newInfractionSeconds = null; + _infractionReason = null; + + await RefreshTable(); + + TimeSpan? GetTimeSpan(int? months, int? days, int? hours, int? minutes, int? seconds) + { + if (months is null + && days is null + && hours is null + && minutes is null + && seconds is null) + return null; + + var now = DateTimeOffset.UtcNow; + var daysInMonth = DateTime.DaysInMonth(now.Year, now.Month); + + var monthSpan = months is null + ? TimeSpan.Zero + : TimeSpan.FromDays(months.Value * daysInMonth); + + var daySpan = days is null + ? TimeSpan.Zero + : TimeSpan.FromDays(days.Value); + + var hourSpan = hours is null + ? TimeSpan.Zero + : TimeSpan.FromHours(hours.Value); + + var minuteSpan = minutes is null + ? TimeSpan.Zero + : TimeSpan.FromMinutes(minutes.Value); + + var secondSpan = seconds is null + ? TimeSpan.Zero + : TimeSpan.FromSeconds(seconds.Value); + + return monthSpan + daySpan + hourSpan + minuteSpan + secondSpan; + } + } + + private async Task RefreshTable() + { + if (TableRef is null) + return; + + await TableRef.ReloadServerData(); + } + + private async Task> LoadInfractions(TableState tableState) + { + var currentUser = DiscordHelper.GetCurrentUser(); + + var sortingCriteria = new[] + { + new SortingCriteria() + { + PropertyName = tableState.SortLabel ?? nameof(InfractionData.Id), + Direction = tableState.SortDirection == MudBlazor.SortDirection.Ascending + ? Data.Models.SortDirection.Ascending + : Data.Models.SortDirection.Descending, + } + }; + + var searchCriteria = new InfractionSearchCriteria + { + GuildId = currentUser!.Guild.Id, + Id = _tableFilter.Id, + Types = _tableFilter.Types, + Subject = _tableFilter.Subject, + SubjectId = _tableFilter.SubjectId, + Creator = _tableFilter.Creator, + CreatedById = _tableFilter.CreatedById, + IsDeleted = _tableFilter.ShowDeleted ? null : false + }; + + var pagingCriteria = new PagingCriteria + { + FirstRecordIndex = tableState.Page * tableState.PageSize, + PageSize = tableState.PageSize, + }; + + var result = await ModerationService.SearchInfractionsAsync( + searchCriteria, + sortingCriteria, + pagingCriteria); + + var outranksValues = new Dictionary(); + + foreach (var (guildId, subjectId) in result.Records + .Select(x => (guildId: x.GuildId, subjectId: x.Subject.Id)) + .Distinct()) + { + outranksValues[subjectId] + = await ModerationService.DoesModeratorOutrankUserAsync(guildId, currentUser.Id, subjectId); + } + + var mapped = result.Records.Select(x => InfractionData.FromInfractionSummary(x, outranksValues)).ToArray(); + + return new TableData + { + Items = mapped, + TotalItems = mapped.Length + }; + } +} diff --git a/Modix.Web/Components/UserLookupField.razor b/Modix.Web/Components/UserLookupField.razor new file mode 100644 index 000000000..37fd58a49 --- /dev/null +++ b/Modix.Web/Components/UserLookupField.razor @@ -0,0 +1,35 @@ +@using MudBlazor +@typeparam T + +
+ + @Label + + @if (ChildContent is not null) + { + @ChildContent + } + else if (Value is not null) + { + @Value + } + else + { + @Default + } +
+ +@code { + + [Parameter] + public string? Label { get; set; } + + [Parameter] + public T? Value { get; set; } + + [Parameter] + public T? Default { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } +} \ No newline at end of file diff --git a/Modix.Web/Models/Commands/Command.cs b/Modix.Web/Models/Commands/Command.cs new file mode 100644 index 000000000..71438879c --- /dev/null +++ b/Modix.Web/Models/Commands/Command.cs @@ -0,0 +1,5 @@ +using Modix.Services.CommandHelp; + +namespace Modix.Web.Models.Commands; + +public record Command(string Name, string Summary, IReadOnlyCollection Aliases, IReadOnlyCollection Parameters, bool IsSlashCommand); diff --git a/Modix.Web/Models/Commands/Module.cs b/Modix.Web/Models/Commands/Module.cs new file mode 100644 index 000000000..e8f111a56 --- /dev/null +++ b/Modix.Web/Models/Commands/Module.cs @@ -0,0 +1,3 @@ +namespace Modix.Web.Models.Commands; + +public record Module(string Name, string Summary, IEnumerable Commands); diff --git a/Modix.Web/Models/Common/ChannelInformation.cs b/Modix.Web/Models/Common/ChannelInformation.cs new file mode 100644 index 000000000..871dd49cf --- /dev/null +++ b/Modix.Web/Models/Common/ChannelInformation.cs @@ -0,0 +1,3 @@ +namespace Modix.Web.Models.Common; + +public record ChannelInformation(ulong Id, string Name) : IAutoCompleteItem; diff --git a/Modix.Web/Models/Common/IAutoCompleteItem.cs b/Modix.Web/Models/Common/IAutoCompleteItem.cs new file mode 100644 index 000000000..6e28089cc --- /dev/null +++ b/Modix.Web/Models/Common/IAutoCompleteItem.cs @@ -0,0 +1,6 @@ +namespace Modix.Web.Models.Common; + +public interface IAutoCompleteItem +{ + public string? Name { get; } +} diff --git a/Modix.Web/Models/Common/ModixUser.cs b/Modix.Web/Models/Common/ModixUser.cs new file mode 100644 index 000000000..b110ab35c --- /dev/null +++ b/Modix.Web/Models/Common/ModixUser.cs @@ -0,0 +1,25 @@ +using Discord; +using Modix.Services.Utilities; + +namespace Modix.Web.Models.Common; + +public sealed class ModixUser : IAutoCompleteItem +{ + public string? Name { get; init; } + public ulong UserId { get; init; } + public string? AvatarUrl { get; init; } + + public static ModixUser FromIGuildUser(IGuildUser user) => new() + { + Name = user.GetDisplayName(), + UserId = user.Id, + AvatarUrl = user.GetDisplayAvatarUrl() ?? user.GetDefaultAvatarUrl() + }; + + public static ModixUser FromNonGuildUser(IUser user) => new() + { + Name = user.GetDisplayName(), + UserId = user.Id, + AvatarUrl = user.GetAvatarUrl() ?? user.GetDefaultAvatarUrl() + }; +} diff --git a/Modix.Web/Models/Common/RoleInformation.cs b/Modix.Web/Models/Common/RoleInformation.cs new file mode 100644 index 000000000..d5a36484c --- /dev/null +++ b/Modix.Web/Models/Common/RoleInformation.cs @@ -0,0 +1,3 @@ +namespace Modix.Web.Models.Common; + +public record RoleInformation(ulong Id, string Name, string Color) : IAutoCompleteItem; diff --git a/Modix.Web/Models/Configuration/DesignatedChannelData.cs b/Modix.Web/Models/Configuration/DesignatedChannelData.cs new file mode 100644 index 000000000..d2e8cbdbd --- /dev/null +++ b/Modix.Web/Models/Configuration/DesignatedChannelData.cs @@ -0,0 +1,5 @@ +using Modix.Data.Models.Core; + +namespace Modix.Web.Models.Configuration; + +public record DesignatedChannelData(long Id, ulong RoleId, DesignatedChannelType ChannelDesignation, string Name); diff --git a/Modix.Web/Models/Configuration/DesignatedRoleData.cs b/Modix.Web/Models/Configuration/DesignatedRoleData.cs new file mode 100644 index 000000000..169c92c19 --- /dev/null +++ b/Modix.Web/Models/Configuration/DesignatedRoleData.cs @@ -0,0 +1,5 @@ +using Modix.Data.Models.Core; + +namespace Modix.Web.Models.Configuration; + +public record DesignatedRoleData(long Id, ulong RoleId, DesignatedRoleType RoleDesignation, string Name); diff --git a/Modix.Web/Models/CookieConstants.cs b/Modix.Web/Models/CookieConstants.cs new file mode 100644 index 000000000..f6ebf766c --- /dev/null +++ b/Modix.Web/Models/CookieConstants.cs @@ -0,0 +1,9 @@ +namespace Modix.Web.Models; + +public static class CookieConstants +{ + public const string SelectedGuild = nameof(SelectedGuild); + public const string ShowInfractionState = nameof(ShowInfractionState); + public const string ShowDeletedInfractions = nameof(ShowDeletedInfractions); + public const string ShowInactivePromotions = nameof(ShowInactivePromotions); +} diff --git a/Modix.Web/Models/DeletedMessages/DeletedMessageInformation.cs b/Modix.Web/Models/DeletedMessages/DeletedMessageInformation.cs new file mode 100644 index 000000000..e87f73a0f --- /dev/null +++ b/Modix.Web/Models/DeletedMessages/DeletedMessageInformation.cs @@ -0,0 +1,27 @@ +using Discord; +using Humanizer.Bytes; +using Modix.Services.Utilities; + +namespace Modix.Web.Models.DeletedMessages; + +public record DeletedMessageInformation(ulong MessageId, DateTimeOffset? SentTime, string? Url, string Username, string Content) +{ + public static DeletedMessageInformation FromIMessage(IMessage message) + { + var content = message.Content; + + if (string.IsNullOrWhiteSpace(content)) + { + if (message.Embeds.Count > 0) + { + content = $"Embed: {message.Embeds.First().Title}: {message.Embeds.First().Description}"; + } + else if (message.Attachments.Count > 0) + { + content = $"Attachment: {message.Attachments.First().Filename} {ByteSize.FromBytes(message.Attachments.First().Size)}"; + } + } + + return new DeletedMessageInformation(message.Id, message.CreatedAt, message.GetJumpUrl(), message.Author.GetDisplayName(), content); + } +} diff --git a/Modix.Web/Models/DeletedMessages/TableFilter.cs b/Modix.Web/Models/DeletedMessages/TableFilter.cs new file mode 100644 index 000000000..48157fbfe --- /dev/null +++ b/Modix.Web/Models/DeletedMessages/TableFilter.cs @@ -0,0 +1,91 @@ +namespace Modix.Web.Models.DeletedMessages; + +public class TableFilter +{ + private string? _author; + public string? Author + { + get => _author; + set + { + if (string.IsNullOrWhiteSpace(value)) + { + _author = null; + AuthorId = null; + } + else if (ulong.TryParse(value, out var subjectId)) + { + AuthorId = subjectId; + } + else + { + _author = value; + } + } + } + + public ulong? AuthorId { get; private set; } + + private string? _createdBy; + public string? CreatedBy + { + get => _createdBy; + set + { + if (string.IsNullOrWhiteSpace(value)) + { + _createdBy = null; + CreatedById = null; + } + else if (ulong.TryParse(value, out var createdById)) + { + CreatedById = createdById; + } + else + { + _createdBy = value; + } + } + } + + public ulong? CreatedById { get; private set; } + + private string? _channel; + public string? Channel + { + get => _channel; + set + { + if (string.IsNullOrWhiteSpace(value)) + { + _channel = null; + ChannelId = null; + } + else if (ulong.TryParse(value, out var channelId)) + { + ChannelId = channelId; + } + else + { + _channel = value; + } + } + } + + public long? BatchId { get; set; } + + public ulong? ChannelId { get; private set; } + public string? Content { get; set; } + public string? Reason { get; set; } + + public void SetBatchId(string? batchId) + { + if (!long.TryParse(batchId, out var id)) + { + BatchId = null; + return; + } + + BatchId = id; + } +} diff --git a/Modix.Web/Models/GuildOption.cs b/Modix.Web/Models/GuildOption.cs new file mode 100644 index 000000000..7307007f5 --- /dev/null +++ b/Modix.Web/Models/GuildOption.cs @@ -0,0 +1,3 @@ +namespace Modix.Web.Models; + +public record GuildOption(ulong Id, string Name, string IconUrl); diff --git a/Modix.Web/Models/Infractions/InfractionData.cs b/Modix.Web/Models/Infractions/InfractionData.cs new file mode 100644 index 000000000..c1491ab7e --- /dev/null +++ b/Modix.Web/Models/Infractions/InfractionData.cs @@ -0,0 +1,43 @@ +using Modix.Data.Models.Core; +using Modix.Data.Models.Moderation; + +namespace Modix.Web.Models.Infractions; + +public record InfractionData( + long Id, + ulong GuildId, + InfractionType Type, + string Reason, + TimeSpan? Duration, + GuildUserBrief Subject, + ModerationActionBrief CreateAction, + ModerationActionBrief? RescindAction, + ModerationActionBrief? DeleteAction, + bool CanBeRescind, + bool CanBeDeleted +) +{ + public static InfractionData FromInfractionSummary(InfractionSummary summary, Dictionary outranksValues) + { + return new InfractionData( + summary.Id, + summary.GuildId, + summary.Type, + summary.Reason, + summary.Duration, + summary.Subject, + + summary.CreateAction, + summary.RescindAction, + summary.DeleteAction, + + summary.RescindAction is null + && summary.DeleteAction is null + && (summary.Type == InfractionType.Mute || summary.Type == InfractionType.Ban) + && outranksValues[summary.Subject.Id], + + summary.DeleteAction is null + && outranksValues[summary.Subject.Id] + ); + } +} diff --git a/Modix.Web/Models/Infractions/TableFilter.cs b/Modix.Web/Models/Infractions/TableFilter.cs new file mode 100644 index 000000000..fc7e112ab --- /dev/null +++ b/Modix.Web/Models/Infractions/TableFilter.cs @@ -0,0 +1,62 @@ +using Modix.Data.Models.Moderation; + +namespace Modix.Web.Models.Infractions; + +public class TableFilter +{ + public long? Id => long.TryParse(IdString, out var id) ? id : null; + public string? IdString { get; set; } + + public InfractionType? Type { get; set; } + public InfractionType[]? Types => Type is not null ? new[] { Type.Value } : null; + + private string? _subject; + public string? Subject + { + get => _subject; + set + { + if (string.IsNullOrWhiteSpace(value)) + { + _subject = null; + SubjectId = null; + } + else if (ulong.TryParse(value, out var subjectId)) + { + SubjectId = subjectId; + } + else + { + _subject = value; + } + } + } + + public ulong? SubjectId { get; private set; } + + private string? _creator; + public string? Creator + { + get => _creator; + set + { + if (string.IsNullOrWhiteSpace(value)) + { + _creator = null; + CreatedById = null; + } + else if (ulong.TryParse(value, out var createdById)) + { + CreatedById = createdById; + } + else + { + _creator = value; + } + } + } + + public ulong? CreatedById { get; private set; } + + public bool ShowDeleted { get; set; } +} diff --git a/Modix.Web/Models/Promotions/CampaignCommentData.cs b/Modix.Web/Models/Promotions/CampaignCommentData.cs new file mode 100644 index 000000000..d245e89a6 --- /dev/null +++ b/Modix.Web/Models/Promotions/CampaignCommentData.cs @@ -0,0 +1,5 @@ +using Modix.Data.Models.Promotions; + +namespace Modix.Web.Models.Promotions; + +public record CampaignCommentData(long Id, PromotionSentiment PromotionSentiment, string Content, DateTimeOffset CreatedAt, bool IsFromCurrentUser); diff --git a/Modix.Web/Models/Promotions/NextRank.cs b/Modix.Web/Models/Promotions/NextRank.cs new file mode 100644 index 000000000..5c58b804a --- /dev/null +++ b/Modix.Web/Models/Promotions/NextRank.cs @@ -0,0 +1,3 @@ +namespace Modix.Web.Models.Promotions; + +public record NextRank(string? Name, string Color); diff --git a/Modix.Web/Models/SessionState.cs b/Modix.Web/Models/SessionState.cs new file mode 100644 index 000000000..b6e6701f5 --- /dev/null +++ b/Modix.Web/Models/SessionState.cs @@ -0,0 +1,10 @@ +namespace Modix.Web.Models; + +public class SessionState +{ + public ulong SelectedGuild { get; set; } + public ulong CurrentUserId { get; set; } + public bool ShowDeletedInfractions { get; set; } + public bool ShowInfractionState { get; set; } + public bool ShowInactivePromotions { get; set; } +} diff --git a/Modix.Web/Models/Stats/GuildStatData.cs b/Modix.Web/Models/Stats/GuildStatData.cs new file mode 100644 index 000000000..db3f09698 --- /dev/null +++ b/Modix.Web/Models/Stats/GuildStatData.cs @@ -0,0 +1,6 @@ +using Modix.Data.Models.Core; +using Modix.Services.GuildStats; + +namespace Modix.Web.Models.Stats; + +public record GuildStatData(string GuildName, List GuildRoleCounts, IReadOnlyCollection TopUserMessageCounts); diff --git a/Modix.Web/Models/Tags/TagData.cs b/Modix.Web/Models/Tags/TagData.cs new file mode 100644 index 000000000..7e6fa3d57 --- /dev/null +++ b/Modix.Web/Models/Tags/TagData.cs @@ -0,0 +1,32 @@ +using Modix.Data.Models.Core; +using Modix.Data.Models.Tags; + +namespace Modix.Web.Models.Tags; + +public record TagData( + string Name, + DateTimeOffset Created, + bool IsOwnedByRole, + GuildUserBrief? OwnerUser, + GuildRoleBrief? OwnerRole, + string? OwnerName, + string Content, + uint Uses, + bool CanMaintain, + TagSummary TagSummary) +{ + public static TagData CreateFromSummary(TagSummary summary) + { + return new TagData( + summary.Name, + summary.CreateAction.Created, + summary.OwnerRole is not null, + summary.OwnerUser, + summary.OwnerRole, + summary.OwnerRole?.Name ?? summary.OwnerUser?.Username, + summary.Content, + summary.Uses, + false, + summary); + } +} diff --git a/Modix.Web/Models/UserLookup/MessageCountPerChannelInformation.cs b/Modix.Web/Models/UserLookup/MessageCountPerChannelInformation.cs new file mode 100644 index 000000000..af3d869cc --- /dev/null +++ b/Modix.Web/Models/UserLookup/MessageCountPerChannelInformation.cs @@ -0,0 +1,3 @@ +namespace Modix.Web.Models.UserLookup; + +public record MessageCountPerChannelInformation(string ChannelName, double Count, string Color); diff --git a/Modix.Web/Models/UserLookup/UserInformation.cs b/Modix.Web/Models/UserLookup/UserInformation.cs new file mode 100644 index 000000000..b3bcdcea6 --- /dev/null +++ b/Modix.Web/Models/UserLookup/UserInformation.cs @@ -0,0 +1,62 @@ +using Discord; +using Discord.WebSocket; +using Modix.Data.Models.Core; +using Modix.Web.Models.Common; + +namespace Modix.Web.Models.UserLookup; + +public record UserInformation( + string Id, + string? Username, + string? Nickname, + string? Discriminator, + string? AvatarUrl, + DateTimeOffset CreatedAt, + DateTimeOffset? JoinedAt, + DateTimeOffset? FirstSeen, + DateTimeOffset? LastSeen, + int Rank, + int Last7DaysMessages, + int Last30DaysMessages, + decimal AverageMessagesPerDay, + int Percentile, + IEnumerable Roles, + bool IsBanned, + string? BanReason, + bool IsGuildMember, + IReadOnlyList MessageCountsPerChannel +) +{ + public static UserInformation FromEphemeralUser( + EphemeralUser ephemeralUser, + GuildUserParticipationStatistics userRank, + IReadOnlyList messages7, + IReadOnlyList messages30, + SocketRole[] roles, + List messageCountsPerChannel) + { + return new UserInformation( + ephemeralUser.Id.ToString(), + ephemeralUser.Username, + ephemeralUser.Nickname, + ephemeralUser.Discriminator, + ephemeralUser.AvatarId != null ? ephemeralUser.GetAvatarUrl(ImageFormat.Auto, 256) : ephemeralUser.GetDefaultAvatarUrl(), + ephemeralUser.CreatedAt, + ephemeralUser.JoinedAt, + ephemeralUser.FirstSeen, + ephemeralUser.LastSeen, + userRank.Rank, + messages7.Sum(x => x.MessageCount), + messages30.Sum(x => x.MessageCount), + userRank.AveragePerDay, + userRank.Percentile, + roles + .Where(x => !x.IsEveryone) + .Select(x => new RoleInformation(x.Id, x.Name, x.Color.ToString())), + ephemeralUser.IsBanned, + ephemeralUser.BanReason, + ephemeralUser.GuildId != default, + messageCountsPerChannel + ); + } +} diff --git a/Modix.Web/Modix.Web.csproj b/Modix.Web/Modix.Web.csproj new file mode 100644 index 000000000..02a9f2313 --- /dev/null +++ b/Modix.Web/Modix.Web.csproj @@ -0,0 +1,21 @@ + + + + enable + enable + Library + true + + + + + + + + + + + + + + diff --git a/Modix.Web/Pages/Commands.razor b/Modix.Web/Pages/Commands.razor new file mode 100644 index 000000000..9950a57c2 --- /dev/null +++ b/Modix.Web/Pages/Commands.razor @@ -0,0 +1,179 @@ +@page "/commands" +@using Modix.Services.CommandHelp; +@using Modix.Services.Utilities; +@using Modix.Web.Components +@using Modix.Web.Models.Commands; +@using MudBlazor; +@using Humanizer; + +Modix - Commands + + + +@if (Modules is not null) +{ + + + + @foreach (var module in Modules.OrderBy(x => x.Name)) + { + + @module.Name + + } + + + + +
+ @foreach (var module in Modules.OrderBy(m => m.Name)) + { + + + @module.Name + + @module.Summary + + @foreach (var command in module.Commands) + { + + @foreach (var alias in command.Aliases) + { +
+ @(command.IsSlashCommand ? '/' : '!')@alias.ToLower() + @if (alias == command.Aliases.First()) + { + @command.Summary +
+ @foreach (var parameter in command.Parameters) + { +
+ @parameter.Name + + @if (parameter.Summary is not null || parameter.Options.Count > 0) + { + var description = $"{parameter.Summary} {string.Join(", ", parameter.Options)}"; + + … + + } + + @parameter.Type + + @if (parameter.IsOptional) + { + + + ? + + } +
+ } +
+ + } +
+ } +
+ } + +
+ + } + +
+
+
+} + + + +@code { + [Inject] + public ICommandHelpService CommandHelpService { get; set; } = null!; + + private IReadOnlyCollection? Modules; + + protected override void OnAfterRender(bool firstRender) + { + if (!firstRender) + return; + + var modules = CommandHelpService.GetModuleHelpData(); + + Modules = modules.Select(m => + { + var commands = m.Commands.Select(c => new Command(c.Name, c.Summary, FormatUtilities.CollapsePlurals(c.Aliases), c.Parameters, c.IsSlashCommand)); + return new Module(m.Name, m.Summary, commands); + }).ToArray(); + + StateHasChanged(); + } +} diff --git a/Modix.Web/Pages/Configuration.razor b/Modix.Web/Pages/Configuration.razor new file mode 100644 index 000000000..a763f58d1 --- /dev/null +++ b/Modix.Web/Pages/Configuration.razor @@ -0,0 +1,61 @@ +@page "/config" +@page "/config/{SubPage}" + +@attribute [Authorize( + Roles = $@" + {nameof(AuthorizationClaim.DesignatedRoleMappingRead)}, + {nameof(AuthorizationClaim.DesignatedChannelMappingRead)}, + {nameof(AuthorizationClaim.AuthorizationConfigure)}")] + +@using Modix.Data.Models.Core; +@using Modix.Web.Components +@using Modix.Web.Components.Configuration +@using MudBlazor + +Modix - Configuration + + + +
+
+ Configuration + + + + + + + + + +
+
+ @if (SubPage == "roles") + { + + + + } + else if (SubPage == "channels") + { + + + + } + else if (SubPage == "claims") + { + + + + } +
+
+ +
+ +@code { + + [Parameter] + public string? SubPage { get; set; } + +} diff --git a/Modix.Web/Pages/CreatePromotion.razor b/Modix.Web/Pages/CreatePromotion.razor new file mode 100644 index 000000000..8a8f5fefa --- /dev/null +++ b/Modix.Web/Pages/CreatePromotion.razor @@ -0,0 +1,128 @@ +@page "/promotions/create" +@attribute [Authorize(Roles = nameof(AuthorizationClaim.PromotionsCreateCampaign))] +@using Modix.Data.Models.Core; +@using Modix.Services.Promotions; +@using Modix.Web.Components +@using Modix.Web.Models; +@using Modix.Web.Models.Common; +@using Modix.Web.Models.Promotions; +@using Modix.Web.Services; +@using MudBlazor + +Modix - Start A Campaign + + + Start a Campaign + + + +

Feel like someone deserves recognition? Start a promotion campaign for them - even if that person is yourself!

+
+ +

Once a campaign is started, users can anonymously comment, voicing their opinions for or against the individual up for promotion

+
+ +

Staff will periodically review campaigns. If approved, the user will be immediately promoted! If not, they may be permanently denied, or further looked into as the campaign runs its course.

+
+
+ + + + + + @user.Name + + + + @if (_selectedUser is not null && _nextRank is not null) + { +
+ @_selectedUser.Name can be promoted to this rank + @_nextRank.Name +
+ +
+ Finally, say a few words on their behalf + + + +
+ + Submit + } + +
+ +
+
+ +@code { + + [Inject] + public IPromotionsService PromotionsService { get; set; } = null!; + + [Inject] + public DiscordHelper DiscordHelper { get; set; } = null!; + + [Inject] + public ISnackbar Snackbar { get; set; } = null!; + + [Inject] + public NavigationManager NavigationManager { get; set; } = null!; + + private ModixUser? _selectedUser; + private string? _promotionComment; + + private NextRank? _nextRank; + + private async Task SelectedUserChanged(ModixUser user) + { + if (user != _selectedUser) + { + _nextRank = null; + _promotionComment = null; + } + + _selectedUser = user; + if (user is null) + return; + + var nextRank = await PromotionsService.GetNextRankRoleForUserAsync(user.UserId); + var currentGuild = DiscordHelper.GetUserGuild(); + + if (nextRank is null) + { + _nextRank = new NextRank("None", "#607d8b"); + } + else + { + _nextRank = new NextRank(nextRank.Name, currentGuild.Roles.First(x => x.Id == nextRank.Id).Color.ToString()); + } + } + + private async Task CreateCampaign() + { + try + { + await PromotionsService.CreateCampaignAsync(_selectedUser!.UserId, _promotionComment); + } + catch (InvalidOperationException ex) + { + Snackbar.Configuration.PositionClass = Defaults.Classes.Position.BottomCenter; + Snackbar.Add(ex.Message, Severity.Error); + return; + } + + NavigationManager.NavigateTo("/promotions"); + } +} diff --git a/Modix.Web/Pages/Error.cshtml b/Modix.Web/Pages/Error.cshtml new file mode 100644 index 000000000..3ff7e625d --- /dev/null +++ b/Modix.Web/Pages/Error.cshtml @@ -0,0 +1,42 @@ +@page +@model Modix.Web.Pages.ErrorModel + + + + + + + + Error + + + + + +
+
+

Error.

+

An error occurred while processing your request.

+ + @if (Model.ShowRequestId) + { +

+ Request ID: @Model.RequestId +

+ } + +

Development Mode

+

+ Swapping to the Development environment displays detailed information about the error that occurred. +

+

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

+
+
+ + + diff --git a/Modix.Web/Pages/Error.cshtml.cs b/Modix.Web/Pages/Error.cshtml.cs new file mode 100644 index 000000000..ad6c2eb22 --- /dev/null +++ b/Modix.Web/Pages/Error.cshtml.cs @@ -0,0 +1,16 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Modix.Web.Pages; + +[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] +[IgnoreAntiforgeryToken] +public class ErrorModel : PageModel +{ + public string? RequestId { get; set; } + + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + public void OnGet() => RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; +} diff --git a/Modix.Web/Pages/Index.razor b/Modix.Web/Pages/Index.razor new file mode 100644 index 000000000..6cf3cd4e6 --- /dev/null +++ b/Modix.Web/Pages/Index.razor @@ -0,0 +1,23 @@ +@page "/" +@using MudBlazor + +Modix - Home + + + + Modix (stylized MODiX) is a general purpose Discord bot written for use on the + C# Discord Server + by + developers like you. + It includes administrative features, a C# compiler / eval module, documentation search, and more. + + + + Modix is constantly in development - if you'd like to contribute, stop by the server, + and check out our GitHub repo! + + + + + + diff --git a/Modix.Web/Pages/Logs.razor b/Modix.Web/Pages/Logs.razor new file mode 100644 index 000000000..65f6cf747 --- /dev/null +++ b/Modix.Web/Pages/Logs.razor @@ -0,0 +1,66 @@ +@page "/logs/{SubPage}" +@page "/logs" +@page "/infractions" +@attribute [Authorize(Roles = nameof(AuthorizationClaim.ModerationRead))] +@using Modix.Data.Models.Core; +@using Modix.Web.Components +@using MudBlazor + +Modix - Logs + + + +
+
+ Logs + + + + + + + +
+
+ @if (SubPage == "infractions") + { + + + + } + else if (SubPage == "deletedMessages") + { + + + + } +
+
+ +
+ +@code { + [Parameter] + public string? SubPage { get; set; } + + [Inject] + public NavigationManager NavigationManager { get; set; } = null!; + + [Parameter] + [SupplyParameterFromQuery] + public string? Subject { get; set; } + + [Parameter] + [SupplyParameterFromQuery] + public string? Id { get; set; } + + protected override void OnAfterRender(bool firstRender) + { + var relativePath = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); + + if (relativePath.StartsWith("infractions")) + { + NavigationManager.NavigateTo($"/logs/{relativePath}", new NavigationOptions { ReplaceHistoryEntry = true, ForceLoad = false }); + } + } +} diff --git a/Modix.Web/Pages/Promotions.razor b/Modix.Web/Pages/Promotions.razor new file mode 100644 index 000000000..2f456b433 --- /dev/null +++ b/Modix.Web/Pages/Promotions.razor @@ -0,0 +1,350 @@ +@page "/promotions" + +@attribute [Authorize(Roles = nameof(AuthorizationClaim.PromotionsRead))] + +@using Modix.Data.Models.Core; +@using Modix.Data.Models.Promotions; +@using Modix.Data.Utilities; +@using Modix.Services.Promotions; +@using Modix.Web.Components; +@using Modix.Web.Models.Promotions; +@using Modix.Web.Models; +@using Modix.Web.Services; +@using MudBlazor +@using Humanizer; +@using Modix.Services.Utilities; + +Modix - Promotions + + + + + Promotion Campaigns +
+ + Start One +
+ + @foreach (var campaign in Campaigns.Where(x => _showInactive ? true : (x.Outcome is null)).OrderByDescending(x => x.Outcome is null).ThenByDescending(x => x.CreateAction.Created)) + { + var isCurrentUserCampaign = CurrentUserId == campaign.Subject.Id; + + var icon = campaign.Outcome switch + { + PromotionCampaignOutcome.Accepted => Icons.Material.Filled.Check, + PromotionCampaignOutcome.Rejected => Icons.Material.Filled.NotInterested, + PromotionCampaignOutcome.Failed => Icons.Material.Filled.Error, + _ => Icons.Material.Filled.HowToVote + }; + + var sentimentRatio = isCurrentUserCampaign ? 0d : (double)campaign.ApproveCount / (campaign.ApproveCount + campaign.OpposeCount); + var sentimentColor = sentimentRatio switch + { + _ when isCurrentUserCampaign => Color.Transparent, + > 0.67 => Color.Success, + > 0.33 => Color.Warning, + _ => Color.Error + }; + + + +
+
+ + + + + @campaign.Subject.GetFullUsername() + + + @campaign.TargetRole.Name +
+ +
+ @if (campaign.Outcome is null) + { + + + + + + + } + +
+ +
+
+
+ + @(isCurrentUserCampaign ? "?" : campaign.ApproveCount.ToString()) +
+
+ + @(isCurrentUserCampaign ? "?" : campaign.OpposeCount.ToString()) +
+
+ +
+
+
+ + Campaign started @campaign.CreateAction.Created.ToString("MM/dd/yy, h:mm:ss tt") + + @if (campaign.Subject.Id == CurrentUserId) + { + + Sorry, you aren't allowed to see comments on your own campaign. + + } + else if (!campaignCommentData.ContainsKey(campaign.Id)) + { + + } + else + { + foreach (var comment in campaignCommentData[campaign.Id].Values.OrderByDescending(x => x.CreatedAt)) + { + var sentimentIcon = comment.PromotionSentiment == PromotionSentiment.Approve ? Icons.Material.Filled.ThumbUp : Icons.Material.Filled.ThumbDown; +
+ + @comment.Content + + @if (comment.IsFromCurrentUser && campaign.CloseAction is null) + { + + Edit + + } + @comment.CreatedAt.ToString("MM/dd/yy, h:mm:ss tt") +
+ + } + + if (campaign.CloseAction is null && !campaignCommentData[campaign.Id].Any(x => x.Value.IsFromCurrentUser)) + { + + } + } +
+
+ } +
+
+ +
+ + + +@code { + [Inject] + public SessionState SessionState { get; set; } = null!; + + [Inject] + public CookieService CookieService { get; set; } = null!; + + [Inject] + public DiscordHelper DiscordHelper { get; set; } = null!; + + [Inject] + public IPromotionsService PromotionsService { get; set; } = null!; + + [Inject] + public IDialogService DialogService { get; set; } = null!; + + [Inject] + public ISnackbar Snackbar { get; set; } = null!; + + private ulong CurrentUserId { get; set; } + + private IReadOnlyCollection Campaigns = Array.Empty(); + private Dictionary RoleColors = new Dictionary(); + private Dictionary> campaignCommentData = new Dictionary>(); + + private bool _showInactive; + + protected override void OnInitialized() + { + _showInactive = SessionState.ShowInactivePromotions; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + var currentUser = DiscordHelper.GetCurrentUser(); + RoleColors = currentUser!.Guild.Roles.ToDictionary(x => x.Id, x => x.Color.ToString()); + + Campaigns = await PromotionsService.SearchCampaignsAsync(new PromotionCampaignSearchCriteria + { + GuildId = currentUser.Guild.Id + }); + + CurrentUserId = currentUser.Id; + + StateHasChanged(); + } + + private async Task ShowInactiveChanged(bool showInactive) + { + _showInactive = showInactive; + await CookieService.SetShowInactivePromotionsAsync(showInactive); + } + + private async Task CampaignExpanded(bool wasExpanded, long campaignId, ulong userId) + { + if (!wasExpanded) + return; + + if (CurrentUserId == userId) + return; + + if (campaignCommentData.ContainsKey(campaignId)) + return; + + var result = await PromotionsService.GetCampaignDetailsAsync(campaignId); + if (result is null) + { + Snackbar.Add($"Unable to load campaign details for campaign id {campaignId}.", Severity.Error); + return; + } + + campaignCommentData[campaignId] = result.Comments + .Where(x => x.ModifyAction is null) + .Select(c => new CampaignCommentData(c.Id, c.Sentiment, c.Content, c.CreateAction.Created, c.CreateAction.CreatedBy.Id == CurrentUserId)) + .ToDictionary(x => x.Id, x => x); + + StateHasChanged(); + } + + private async Task OnCampaignCommentCreation(long campaignId, GuildUserBrief campaignSubject, PromotionSentiment sentiment, string? content) + { + try + { + var promotionActionSummary = await PromotionsService.AddCommentAsync(campaignId, sentiment, content); + var newComment = promotionActionSummary.NewComment; + + campaignCommentData[campaignId][newComment!.Id] = new CampaignCommentData(newComment.Id, newComment.Sentiment, newComment.Content, promotionActionSummary.Created, true); + } + catch (InvalidOperationException ex) + { + Snackbar.Add(ex.Message, Severity.Error); + return; + } + + var username = campaignSubject.GetFullUsername(); + Snackbar.Add($"Added comment to campaign for user {username}.", Severity.Success); + } + + private async Task ToggleEditDialog(long campaignId, long commentId, PromotionSentiment oldPromotionSentiment, string oldContent) + { + var dialogParams = new DialogParameters + { + { x => x.PromotionSentiment, oldPromotionSentiment }, + { x => x.Content, oldContent} + }; + + var dialog = DialogService.Show("", dialogParams); + var result = await dialog.Result; + + if (result.Canceled) + return; + + var (newPromotionSentiment, newContent) = ((PromotionSentiment, string))result.Data; + + try + { + var promotionActionSummary = await PromotionsService.UpdateCommentAsync(commentId, newPromotionSentiment, newContent); + var newComment = promotionActionSummary.NewComment; + + campaignCommentData[campaignId].Remove(commentId); + campaignCommentData[campaignId][newComment!.Id] = new CampaignCommentData(newComment.Id, newComment.Sentiment, newComment.Content, promotionActionSummary.Created, true); + } + catch (InvalidOperationException ex) + { + Snackbar.Add(ex.Message, Severity.Error); + return; + } + + Snackbar.Add("Campaign vote was updated.", Severity.Success); + } + + private async Task AcceptCampaign(PromotionCampaignSummary campaign) + { + var timeSince = DateTime.UtcNow - campaign.CreateAction.Created; + + var username = campaign.Subject.GetFullUsername(); + bool force = false; + if (timeSince < PromotionCampaignEntityExtensions.CampaignAcceptCooldown) + { + var timeLeftHumanized = campaign.GetTimeUntilCampaignCanBeClosed().Humanize(3); + var dialogParams = new DialogParameters + { + { x => x.Content, $"There is {timeLeftHumanized} left on the campaign. Do you want to force accept the campaign for {username}?" } + }; + + var dialog = DialogService.Show("", dialogParams); + var confirmationResult = await dialog.Result; + + if (confirmationResult.Canceled) + { + Snackbar.Add("Action was cancelled", Severity.Info); + return; + } + + force = true; + } + + try + { + await PromotionsService.AcceptCampaignAsync(campaign.Id, force); + } + catch (InvalidOperationException ex) + { + Snackbar.Add(ex.Message, Severity.Error); + return; + } + + campaign.Outcome = PromotionCampaignOutcome.Accepted; + Snackbar.Add($"Campaign for '{username}' was accepted.", Severity.Success); + } + + private async Task RejectCampaign(PromotionCampaignSummary campaign) + { + try + { + await PromotionsService.RejectCampaignAsync(campaign.Id); + + } + catch (InvalidOperationException ex) + { + Snackbar.Add(ex.Message, Severity.Error); + return; + } + + var username = campaign.Subject.GetFullUsername(); + campaign.Outcome = PromotionCampaignOutcome.Rejected; + Snackbar.Add($"Campaign for '{username}' was rejected.", Severity.Success); + } +} diff --git a/Modix.Web/Pages/Stats.razor b/Modix.Web/Pages/Stats.razor new file mode 100644 index 000000000..9220d27d4 --- /dev/null +++ b/Modix.Web/Pages/Stats.razor @@ -0,0 +1,116 @@ +@page "/stats" +@attribute [Authorize] +@using Modix.Data.Models.Core; +@using Modix.Services.GuildStats; +@using Modix.Web.Models.Stats; +@using Modix.Web.Services; +@using MudBlazor + +Modix - Stats + +@if (Data is not null) +{ + + Statistics for C# + + + + + Role Distribution + + + + + + @foreach (var role in Data.GuildRoleCounts) + { + var channelColorStyle = $"border: 1px solid {role.Color}"; + + @($"{role.Name} ({role.Count})") + + } + + + + + + + + + + + Most Active Users + of the last 30 days + + + @foreach (var stat in Data.TopUserMessageCounts) + { + var rankSymbol = stat.Rank switch + { + 1 => "🥇", + 2 => "🥈", + 3 => "🥉", + _ => null + }; + + var username = stat.Username; + username += stat.Discriminator == "0000" ? string.Empty : $"#{stat.Discriminator}"; + + + @($"{rankSymbol ?? $"{stat.Rank}."} {username}") + + @stat.MessageCount messages + + } + + + + + + + +} + +@code { + GuildStatData Data { get; set; } = null!; + List GuildRoleCountView { get; set; } = null!; + + [Inject] + IGuildStatService GuildStatService { get; set; } = null!; + + [Inject] + DiscordHelper DiscordHelper { get; set; } = null!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + var currentUser = DiscordHelper.GetCurrentUser(); + + var roleCounts = await GuildStatService.GetGuildMemberDistributionAsync(currentUser!.Guild); + var messageCounts = await GuildStatService.GetTopMessageCounts(currentUser.Guild, currentUser.Id); + + Data = new GuildStatData(currentUser.Guild.Name, roleCounts, messageCounts); + GuildRoleCountView = roleCounts; + + StateHasChanged(); + } + + private void SelectedChannelsChanged(MudChip[] chips) + { + var roles = chips.Select(x => x.Value).Cast(); + GuildRoleCountView = Data.GuildRoleCounts.Where(x => roles.Contains(x.Name)).ToList(); + } +} diff --git a/Modix.Web/Pages/Tags.razor b/Modix.Web/Pages/Tags.razor new file mode 100644 index 000000000..561109454 --- /dev/null +++ b/Modix.Web/Pages/Tags.razor @@ -0,0 +1,189 @@ +@page "/tags" +@attribute [Authorize] +@using Modix.Data.Models.Core; +@using Modix.Data.Models.Tags; +@using Modix.Services.Tags; +@using Modix.Web.Models; +@using Modix.Web.Models.Common; +@using Modix.Web.Models.Tags; +@using Modix.Web.Services; +@using MudBlazor +@using System.Globalization; + +Modix - Tags + + + + + Tags + @if (Data is not null && Roles is not null) + { + + + Create Tag + + + + + Preview + + + + + Save + + + Cancel + + + +
+
+ + Create + + Refresh +
+ + +
+ + + + Name + Last Modified + Owner + Content + Uses + + + @tag.Name + @tag.Created.ToString("MM/dd/yy, h:mm:ss tt") + @if (tag.OwnerRole is not null) + { + var roleColor = Roles[tag.OwnerRole.Id].Color; + @@@tag.OwnerName + } + else + { + @tag.OwnerName + } + + + + @tag.Uses + + + + + + } +
+ +
+ +@code { + [Inject] + private ITagService TagService { get; set; } = null!; + + [Inject] + private DiscordHelper DiscordHelper { get; set; } = null!; + + [Inject] + private IDialogService DialogService { get; set; } = null!; + + [Inject] + private ISnackbar Snackbar { get; set; } = null!; + + [Parameter] + [SupplyParameterFromQuery] + public string? Query { get; set; } + + private Dictionary? Roles { get; set; } + private TagData[]? Data { get; set; } + + private string? _tagNameValue; + private string? _tagContentValue; + private bool _createDialogVisible; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + await FetchData(); + + StateHasChanged(); + } + + private async Task FetchData() + { + var currentGuild = DiscordHelper.GetUserGuild(); + + var summaries = await TagService.GetSummariesAsync(new TagSearchCriteria + { + GuildId = currentGuild.Id, + }); + + Data = summaries + .Select(TagData.CreateFromSummary) + .ToArray(); + + Roles = currentGuild.Roles + .Select(x => new RoleInformation(x.Id, x.Name, x.Color.ToString())) + .ToDictionary(x => x.Id, x => x); + } + + private bool FilterFunction(TagData tag) + { + if (string.IsNullOrWhiteSpace(Query)) + return true; + + if (tag.OwnerUser is not null && (tag.OwnerUser.Username.Contains(Query, StringComparison.OrdinalIgnoreCase) || tag.OwnerUser.Id.ToString() == Query)) + return true; + + if (tag.OwnerRole is not null && (tag.OwnerRole.Name.Contains(Query, StringComparison.OrdinalIgnoreCase) || tag.OwnerRole.Id.ToString() == Query)) + return true; + + if (tag.Name.Contains(Query, StringComparison.OrdinalIgnoreCase)) + return true; + + if (tag.Content.Contains(Query, StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + private async Task SaveTag() + { + try + { + var currentUser = DiscordHelper.GetCurrentUser(); + + await TagService.CreateTagAsync(currentUser!.Guild.Id, currentUser.Id, _tagNameValue, _tagContentValue); + var createdTag = await TagService.GetTagAsync(currentUser.Guild.Id, _tagNameValue); + + Data = Data!.Append(TagData.CreateFromSummary(createdTag)).ToArray(); + Snackbar.Add($"Tag '{_tagNameValue}' created.", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add(ex.Message, Severity.Error); + } + finally + { + _tagNameValue = null; + _tagContentValue = null; + + _createDialogVisible = false; + } + } + + private void ToggleDialog() + { + _createDialogVisible = !_createDialogVisible; + } +} diff --git a/Modix.Web/Pages/UserLookup.razor b/Modix.Web/Pages/UserLookup.razor new file mode 100644 index 000000000..30c3b7976 --- /dev/null +++ b/Modix.Web/Pages/UserLookup.razor @@ -0,0 +1,182 @@ +@page "/userlookup" +@attribute [Authorize] +@using Discord; +@using Modix.Data.Repositories; +@using Modix.Services.Core; +@using Modix.Services.Utilities; +@using Modix.Web.Components +@using Modix.Web.Models; +@using Modix.Web.Models.UserLookup; +@using Modix.Web.Services +@using MudBlazor +@using Discord.WebSocket +@using System.Linq.Expressions; +@using MudBlazor.Charts +@using System.Globalization; +@using Humanizer; +@using Modix.Web.Models.Common; + +Modix - User Lookup + + + + User Lookup@(userInformation is null ? null : $" - {userInformation.Username + (userInformation.Discriminator == "0000" ? "" : "#" + userInformation.Discriminator)}") + + +
+ + + + @user.Name + + +
+ + @if (userInformation is not null) + { + User Information +
+ + + + + +
+ +
+
+ + Guild Participation + + + + + + + + + Member Information + + + + + + + +
+ @if (!userInformation.Roles.Any()) + { + No roles assigned + } + else + { + @foreach (var role in userInformation.Roles) + { + var roleName = $"@{role.Name}"; + var roleColorStyle = $"border: 1px solid {role.Color}"; + + @roleName + } + } +
+
+
+ + Messages by Channel + + + + @foreach (var channel in userInformation.MessageCountsPerChannel) + { + var channelColorStyle = $"border: 1px solid {channel.Color}"; + + @($"{channel.ChannelName} ({channel.Count})") + + } + + + + + } + +
+ +@code { + MessageCountPerChannelInformation[] messageCountsPerChannelView = Array.Empty(); + UserInformation? userInformation = null; + + [Inject] + public DiscordHelper DiscordHelper { get; set; } = null!; + + [Inject] + public IUserService UserService { get; set; } = null!; + + [Inject] + public IMessageRepository MessageRepository { get; set; } = null!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + var currentUser = DiscordHelper.GetCurrentUser(); + await SelectedUserChanged(ModixUser.FromIGuildUser(currentUser!)); + + StateHasChanged(); + } + + private void SelectedChannelsChanged(MudChip[] chips) + { + var channels = chips.Select(x => x.Value).Cast(); + messageCountsPerChannelView = userInformation!.MessageCountsPerChannel + .Where(x => channels.Contains(x.ChannelName)) + .ToArray(); + } + + private async Task SelectedUserChanged(ModixUser user) + { + if (user is null) + return; + + var currentGuild = DiscordHelper.GetUserGuild(); + + var ephemeralUser = await UserService.GetUserInformationAsync(currentGuild.Id, user.UserId); + + var userRank = await MessageRepository.GetGuildUserParticipationStatistics(currentGuild.Id, user.UserId); + var messages7 = await MessageRepository.GetGuildUserMessageCountByDate(currentGuild.Id, user.UserId, TimeSpan.FromDays(7)); + var messages30 = await MessageRepository.GetGuildUserMessageCountByDate(currentGuild.Id, user.UserId, TimeSpan.FromDays(30)); + + var roles = ephemeralUser!.RoleIds + .Select(x => currentGuild.GetRole(x)) + .OrderByDescending(x => x.IsHoisted) + .ThenByDescending(x => x.Position) + .ToArray(); + + var timespan = DateTimeOffset.UtcNow - DateTimeOffset.MinValue; + var result = await MessageRepository.GetGuildUserMessageCountByChannel(currentGuild.Id, user.UserId, timespan); + var colors = ColorUtils.GetRainbowColors(result.Count); + + var messageCountsPerChannel = result + .Select((x, i) => new MessageCountPerChannelInformation(x.ChannelName, x.MessageCount, colors[i++].ToString())) + .OrderByDescending(x => x.Count) + .ToList(); + + userInformation = UserInformation.FromEphemeralUser(ephemeralUser, userRank, messages7, messages30, roles, messageCountsPerChannel); + + messageCountsPerChannelView = userInformation.MessageCountsPerChannel.ToArray(); + } +} diff --git a/Modix.Web/Pages/_Host.cshtml b/Modix.Web/Pages/_Host.cshtml new file mode 100644 index 000000000..0d78d30c7 --- /dev/null +++ b/Modix.Web/Pages/_Host.cshtml @@ -0,0 +1,46 @@ +@page "/" +@using Microsoft.AspNetCore.Components.Web +@using Modix.Web.Models; +@namespace Modix.Web.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + + + + + + + + + + + + + + + + + + + + +
+ + An error has occurred. This application may no longer respond until reloaded. + + + An unhandled exception has occurred. See browser dev tools for details. + + Reload + 🗙 +
+ + + + + + diff --git a/Modix.Web/Security/ClaimsMiddleware.cs b/Modix.Web/Security/ClaimsMiddleware.cs new file mode 100644 index 000000000..20b89069b --- /dev/null +++ b/Modix.Web/Security/ClaimsMiddleware.cs @@ -0,0 +1,38 @@ +using System.Security.Claims; +using Discord.WebSocket; +using Modix.Services.Core; +using Modix.Web.Models; + +namespace Modix.Web.Security; + +public class ClaimsMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, IAuthorizationService authorizationService, DiscordSocketClient discordClient) + { + var userId = context.User.FindFirst(x => x.Type == ClaimTypes.NameIdentifier)?.Value; + if (!ulong.TryParse(userId, out var userSnowflake)) + { + await next(context); + return; + } + + var selectedGuild = context.Request.Cookies[CookieConstants.SelectedGuild]; + _ = ulong.TryParse(selectedGuild, out var selectedGuildId); + + if (context.User.Identity is not ClaimsIdentity claimsIdentity) + { + await next(context); + return; + } + + var currentGuild = discordClient.GetGuild(selectedGuildId) ?? discordClient.Guilds.First(); + var currentUser = currentGuild.GetUser(userSnowflake); + + var claims = (await authorizationService.GetGuildUserClaimsAsync(currentUser)) + .Select(d => new Claim(ClaimTypes.Role, d.ToString())); + + claimsIdentity.AddClaims(claims); + + await next(context); + } +} diff --git a/Modix.Web/Services/CookieService.cs b/Modix.Web/Services/CookieService.cs new file mode 100644 index 000000000..fe60141be --- /dev/null +++ b/Modix.Web/Services/CookieService.cs @@ -0,0 +1,34 @@ +using Microsoft.JSInterop; +using Modix.Web.Models; + +namespace Modix.Web.Services; + +public class CookieService(IJSRuntime jsRuntime, SessionState sessionState) +{ + public async Task SetSelectedGuildAsync(ulong guildId) + { + await SetCookieAsync(CookieConstants.SelectedGuild, guildId); + sessionState.SelectedGuild = guildId; + } + + public async Task SetShowDeletedInfractionsAsync(bool showDeleted) + { + await SetCookieAsync(CookieConstants.ShowDeletedInfractions, showDeleted); + sessionState.ShowDeletedInfractions = showDeleted; + } + + public async Task SetShowInfractionStateAsync(bool showInfractionState) + { + await SetCookieAsync(CookieConstants.ShowInfractionState, showInfractionState); + sessionState.ShowInfractionState = showInfractionState; + } + + public async Task SetShowInactivePromotionsAsync(bool showInactivePromotions) + { + await SetCookieAsync(CookieConstants.ShowInactivePromotions, showInactivePromotions); + sessionState.ShowInactivePromotions = showInactivePromotions; + } + + private async Task SetCookieAsync(string key, T value) + => await jsRuntime.InvokeVoidAsync("eval", $"document.cookie = \"{key}={value}; path=/\";"); +} diff --git a/Modix.Web/Services/DiscordHelper.cs b/Modix.Web/Services/DiscordHelper.cs new file mode 100644 index 000000000..462a8408b --- /dev/null +++ b/Modix.Web/Services/DiscordHelper.cs @@ -0,0 +1,94 @@ +using Discord; +using Discord.WebSocket; +using Modix.Services.Core; +using Modix.Web.Models; +using Modix.Web.Models.Common; + +namespace Modix.Web.Services; + +public class DiscordHelper(DiscordSocketClient client, IUserService userService, SessionState sessionState) +{ + public SocketGuild GetUserGuild() + { + if (sessionState.SelectedGuild != 0) + return client.GetGuild(sessionState.SelectedGuild); + + return client.Guilds.First(); + } + + public IEnumerable GetGuildOptions() + { + var currentUser = GetCurrentUser(); + if (currentUser is null) + return Array.Empty(); + + return client + .Guilds + .Where(d => d.GetUser(currentUser.Id) != null) + .Select(d => new GuildOption(d.Id, d.Name, d.IconUrl)); + } + + public SocketGuildUser? GetCurrentUser() + { + var currentGuild = GetUserGuild(); + return currentGuild.GetUser(sessionState.CurrentUserId); + } + + public async Task> AutoCompleteAsync(string query) + { + var userGuild = GetUserGuild(); + + if (userGuild?.Users is null) + return Array.Empty(); + + var result = userGuild.Users + .Where(d => d.Username.Contains(query, StringComparison.OrdinalIgnoreCase) || d.Id.ToString() == query) + .Take(10) + .Select(ModixUser.FromIGuildUser); + + if (!result.Any() && ulong.TryParse(query, out var userId)) + { + var user = await userService.GetUserInformationAsync(userGuild.Id, userId); + + if (user is not null) + { + result = result.Append(ModixUser.FromNonGuildUser(user)); + } + } + + return result; + } + + public IEnumerable AutoCompleteRoles(string query) + { + if (query.StartsWith('@')) + { + query = query[1..]; + } + + var currentGuild = GetUserGuild(); + IEnumerable result = currentGuild.Roles; + + if (!string.IsNullOrWhiteSpace(query)) + { + result = result.Where(d => d.Name.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + + return result.Take(10).Select(d => new RoleInformation(d.Id, d.Name, d.Color.ToString())); + } + + public IEnumerable AutocompleteChannels(string query) + { + if (query.StartsWith('#')) + { + query = query[1..]; + } + + var currentGuild = GetUserGuild(); + return currentGuild.Channels + .Where(d => d is SocketTextChannel + && d.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) + .Take(10) + .Select(d => new ChannelInformation(d.Id, d.Name)); + } +} diff --git a/Modix.Web/Setup.cs b/Modix.Web/Setup.cs new file mode 100644 index 000000000..2a619ac92 --- /dev/null +++ b/Modix.Web/Setup.cs @@ -0,0 +1,55 @@ +using AspNet.Security.OAuth.Discord; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Modix.Web.Models; +using Modix.Web.Security; +using Modix.Web.Services; +using MudBlazor; +using MudBlazor.Services; + +namespace Modix.Web; + +public static class Setup +{ + public static WebApplication ConfigureBlazorApplication(this WebApplication app) + { + if (!app.Environment.IsDevelopment()) + { + app.UseExceptionHandler("/Error"); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); + } + + app.UseHttpsRedirection(); + + app.UseStaticFiles(); + + app.UseRouting(); + + app.UseRequestLocalization("en-US"); + app.UseMiddleware(); + app.UseAuthorization(); + + app.MapGet("/login", async (context) => await context.ChallengeAsync(DiscordAuthenticationDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = "/" })); + app.MapGet("/logout", async (context) => await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = "/" })); + + app.MapBlazorHub(); + app.MapFallbackToPage("/_Host"); + + return app; + } + + public static IServiceCollection ConfigureBlazorServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddMudServices(); + services.AddMudMarkdownServices(); + + services.AddRazorPages(); + services.AddServerSideBlazor(); + + return services; + } +} diff --git a/Modix.Web/Shared/MainLayout.razor b/Modix.Web/Shared/MainLayout.razor new file mode 100644 index 000000000..448f695bb --- /dev/null +++ b/Modix.Web/Shared/MainLayout.razor @@ -0,0 +1,37 @@ +@using Modix.Data.Models.Core; +@using MudBlazor +@inherits LayoutComponentBase + + + + + + + + + + + + @Body + + + + + +@code { + + private MudTheme _theme = new() + { + Palette = new() + { + Primary = new("#803788"), + }, + Typography = new() + { + Default = new() + { + LetterSpacing = "0" + } + } + }; +} diff --git a/Modix.Web/Shared/MiniUser.razor b/Modix.Web/Shared/MiniUser.razor new file mode 100644 index 000000000..73d1bc300 --- /dev/null +++ b/Modix.Web/Shared/MiniUser.razor @@ -0,0 +1,93 @@ +@using AspNet.Security.OAuth.Discord; +@using Discord.WebSocket; +@using Modix.Web.Models; +@using Modix.Web.Services; +@using MudBlazor + +
+ @if (AvatarUrl is not null && Username is not null) + { + + +
+ + @Username + +
+
+ + Log Out + +
+ } + + + +
+ + +
+
+ + @foreach (var guildOption in GuildOptions) + { + + + @guildOption.Name + + } + +
+
+ + + + +@code { + [CascadingParameter] + public Task? AuthenticationState { get; set; } = null!; + + [Inject] + public DiscordHelper DiscordHelper { get; set; } = null!; + + private string? AvatarUrl { get; set; } + private string? Username { get; set; } + + private IEnumerable GuildOptions { get; set; } = Array.Empty(); + private SocketGuild? SelectedGuild { get; set; } + + [Inject] + public CookieService CookieService { get; set; } = null!; + + [Inject] + public NavigationManager NavigationManager { get; set; } = null!; + + protected override async Task OnInitializedAsync() + { + if (AuthenticationState is null) + return; + + var authState = await AuthenticationState; + if (!authState.User.Identity?.IsAuthenticated ?? false) + return; + + var avatarHash = authState.User.FindFirst(x => x.Type == DiscordAuthenticationConstants.Claims.AvatarHash)?.Value; + var user = DiscordHelper.GetCurrentUser(); + + AvatarUrl = $"https://cdn.discordapp.com/avatars/{user!.Id}/{avatarHash}.png"; + Username = authState.User.Identity?.Name; + + GuildOptions = DiscordHelper.GetGuildOptions(); + SelectedGuild = user.Guild; + } + + private async Task SelectGuild(ulong guildId) + { + await CookieService.SetSelectedGuildAsync(guildId); + NavigationManager.NavigateTo(NavigationManager.Uri, true); + } +} diff --git a/Modix.Web/Shared/NavMenu.razor b/Modix.Web/Shared/NavMenu.razor new file mode 100644 index 000000000..07fefc615 --- /dev/null +++ b/Modix.Web/Shared/NavMenu.razor @@ -0,0 +1,43 @@ +@using AspNet.Security.OAuth.Discord; +@using Discord.WebSocket; +@using Modix.Data.Models.Core; +@using Modix.Web.Models; +@using Modix.Web.Services; +@using MudBlazor; +@using System.Security.Claims; + + + +
+ + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+ +@code { + + private bool _drawerVisible; + + private void ToggleDrawer() => _drawerVisible = !_drawerVisible; +} diff --git a/Modix.Web/Shared/NavMenuLinks.razor b/Modix.Web/Shared/NavMenuLinks.razor new file mode 100644 index 000000000..e42bd3afc --- /dev/null +++ b/Modix.Web/Shared/NavMenuLinks.razor @@ -0,0 +1,52 @@ +@using AspNet.Security.OAuth.Discord; +@using Discord.WebSocket; +@using Modix.Data.Models.Core; +@using Modix.Web.Models; +@using Modix.Web.Services; +@using MudBlazor + + + + + Home + Stats + Commands + User Lookup + Tags + + + Promotions + + + + Logs + + + + Config + + + + +
+ Home + Commands +
+ +
+ Log In +
+
+
+
+ + + +@code { + +} diff --git a/Modix.Web/_Imports.razor b/Modix.Web/_Imports.razor new file mode 100644 index 000000000..d4ac1b7ad --- /dev/null +++ b/Modix.Web/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Modix.Web +@using Modix.Web.Shared diff --git a/Modix.Web/wwwroot/css/site.css b/Modix.Web/wwwroot/css/site.css new file mode 100644 index 000000000..366ccbe10 --- /dev/null +++ b/Modix.Web/wwwroot/css/site.css @@ -0,0 +1,17 @@ +@media (max-width: 600px) { + .width-sm { + width: 100%; + } +} + +.center-text { + text-align: center !important; +} + +.vertical-top { + vertical-align: top !important; +} + +.vertical-bottom { + vertical-align: bottom; +} \ No newline at end of file diff --git a/Modix.Web/wwwroot/favicon.ico b/Modix.Web/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..80429cc0b24fab0731c95da6f2062ec4657629cb GIT binary patch literal 1150 zcmZ{iKWGzS7{=dAE_caYE|=?Fb4^0hh@g^6)gUM?R#0n^(pC^b5C<1mLHx6{!J&Vg z9EymmI=P6GlV}GOI(2XmTcoI+MC_KW{@$bwt+p4QyYGA7=Xu`u`|^qy{PKC>U6Y%p zNI^sn0YXGNaXupCLbrqV{-0N&ph}TUCgWR{bqb8ywmsz?Ng;4^L*#*#uk zlF6i;A0F;{oMTN~(wR&><2VO(4UVQ#soAXO`N~hFtOoj3`gj(GVaoGzU40bw%CSbn zFyV97Ax%(o8r;=&7)JULeCF|3<7@-jY_^`u<&1KWqUs)HtAiXqVAVf@ye*XnoZdOQkLUDL5_qyzV&e2{cXc1paII zpZU&NmVE)vPuBAr8ym7UFYxpX`^f44^qu`#+fLWG(<}JhAlCpV$t{8(^mQB$8JoD& zuD#7?i2OIOq<-+ETJ2BZah2FA_{I8~*7kMaw>?2mPkTr0VR&o!-_rnlouA)^kyzAR z9LEVg_ij&q(-ZA|@QOQ%Ir{>5!Tgt1(_gFYChf-PTkm2&eLkiCQG8x9o4tCL<&~8# zZ?A>U8*^(jXCq(?uOU2SaPY`pk{xmFuTY4kudirwM?U|*%}DIvY~Qp_i~Q*qdFP9K dnh}|M-@z>3ra@eh6j=r9wnz)EYFbkr`~~2jXl(!h literal 0 HcmV?d00001 diff --git a/Modix.sln b/Modix.sln index 1d4efdd26..fbd9c65ea 100644 --- a/Modix.sln +++ b/Modix.sln @@ -59,7 +59,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "wiki", "wiki", "{092916B2-3 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Modix.Analyzers", "Modix.Analyzers\Modix.Analyzers.csproj", "{6009AEDD-BE3B-40BF-B9C4-4C3796F58609}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modix.Analyzers.Test", "Modix.Analyzers.Test\Modix.Analyzers.Test.csproj", "{9A28A475-067B-4CBD-94BC-CA31C0D1555A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Modix.Analyzers.Test", "Modix.Analyzers.Test\Modix.Analyzers.Test.csproj", "{9A28A475-067B-4CBD-94BC-CA31C0D1555A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Modix.Web", "Modix.Web\Modix.Web.csproj", "{2280A9D0-358E-4668-8855-6832725C740A}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -203,6 +205,18 @@ Global {9A28A475-067B-4CBD-94BC-CA31C0D1555A}.Release|x64.Build.0 = Release|Any CPU {9A28A475-067B-4CBD-94BC-CA31C0D1555A}.Release|x86.ActiveCfg = Release|Any CPU {9A28A475-067B-4CBD-94BC-CA31C0D1555A}.Release|x86.Build.0 = Release|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Debug|x64.ActiveCfg = Debug|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Debug|x64.Build.0 = Debug|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Debug|x86.ActiveCfg = Debug|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Debug|x86.Build.0 = Debug|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Release|Any CPU.Build.0 = Release|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Release|x64.ActiveCfg = Release|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Release|x64.Build.0 = Release|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Release|x86.ActiveCfg = Release|Any CPU + {2280A9D0-358E-4668-8855-6832725C740A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Modix/Modix.csproj b/Modix/Modix.csproj index d262c600f..e731ad0d9 100644 --- a/Modix/Modix.csproj +++ b/Modix/Modix.csproj @@ -56,6 +56,7 @@ + diff --git a/Modix/Program.cs b/Modix/Program.cs index 628166e7a..a79dae8c5 100644 --- a/Modix/Program.cs +++ b/Modix/Program.cs @@ -1,15 +1,26 @@ using System; using System.Diagnostics; using System.IO; - -using Microsoft.AspNetCore; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Modix.Authentication; +using Modix.Configuration; +using Modix.Data; using Modix.Data.Models.Core; using Modix.Services.CodePaste; using Modix.Services.Utilities; +using Modix.Web; +using Newtonsoft.Json.Converters; using Serilog; using Serilog.Events; using Serilog.Formatting.Compact; @@ -20,17 +31,14 @@ public class Program { public static int Main(string[] args) { - const string DEVELOPMENT_ENVIRONMENT_VARIABLE = "ASPNETCORE_ENVIRONMENT"; - const string DEVELOPMENT_ENVIRONMENT_KEY = "Development"; - - var environment = Environment.GetEnvironmentVariable(DEVELOPMENT_ENVIRONMENT_VARIABLE); + var builder = WebApplication.CreateBuilder(args); - var configBuilder = new ConfigurationBuilder() + var configBuilder = builder.Configuration .AddEnvironmentVariables("MODIX_") .AddJsonFile("developmentSettings.json", optional: true, reloadOnChange: false) .AddKeyPerFile("/run/secrets", true); - if(environment is DEVELOPMENT_ENVIRONMENT_KEY) + if (builder.Environment.IsDevelopment()) { configBuilder.AddUserSecrets(); } @@ -39,47 +47,29 @@ public static int Main(string[] args) var config = new ModixConfig(); builtConfig.Bind(config); - var loggerConfig = new LoggerConfiguration() - .MinimumLevel.Verbose() - .MinimumLevel.Override("Microsoft", LogEventLevel.Information) - .MinimumLevel.Override("Modix.DiscordSerilogAdapter", LogEventLevel.Information) - .Enrich.FromLogContext() - .WriteTo.Logger(subLoggerConfig => subLoggerConfig - .MinimumLevel.Information() - // .MinimumLevel.Override() is not supported for sub-loggers, even though the docs don't specify this. See https://github.com/serilog/serilog/pull/1033 - .Filter.ByExcluding("SourceContext like 'Microsoft.%' and @l in ['Information', 'Debug', 'Verbose']") - .WriteTo.Console() - .WriteTo.File(Path.Combine("logs", "{Date}.log"), rollingInterval: RollingInterval.Day)) - .WriteTo.File( - new RenderedCompactJsonFormatter(), - Path.Combine("logs", "{Date}.clef"), - rollingInterval: RollingInterval.Day, - retainedFileCountLimit: 2); + ConfigureServices(builder, builtConfig, config); - var seqEndpoint = config.SeqEndpoint; - var seqKey = config.SeqKey; - - if (seqEndpoint != null && seqKey == null) // seq is enabled without a key - { - loggerConfig = loggerConfig.WriteTo.Seq(seqEndpoint); + if (config.UseBlazor) + { + builder.Services.ConfigureBlazorServices(); } - else if (seqEndpoint != null && seqKey != null) //seq is enabled with a key + else { - loggerConfig = loggerConfig.WriteTo.Seq(seqEndpoint, apiKey: seqKey); + ConfigureVueServices(builder.Services); } - var webhookId = config.LogWebhookId; - var webhookToken = config.LogWebhookToken; + var host = builder.Build(); - var host = CreateHostBuilder(args, builtConfig).Build(); + ConfigureCommon(host); - if (webhookId.HasValue && webhookToken != null) + if (config.UseBlazor) { - loggerConfig = loggerConfig - .WriteTo.DiscordWebhookSink(webhookId.Value, webhookToken, LogEventLevel.Error, host.Services.GetRequiredService()); + host.ConfigureBlazorApplication(); + } + else + { + ConfigureVueApplication(host); } - - Log.Logger = loggerConfig.CreateLogger(); try { @@ -105,14 +95,170 @@ public static int Main(string[] args) } } - public static IHostBuilder CreateHostBuilder(string[] args, IConfiguration config) - => Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => + private static void ConfigureServices(WebApplicationBuilder builder, IConfiguration configuration, ModixConfig modixConfig) + { + builder.Host.UseSerilog((ctx, sp, lc) => + { + lc.MinimumLevel.Verbose() + .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Override("Modix.DiscordSerilogAdapter", LogEventLevel.Information) + .Enrich.FromLogContext() + .WriteTo.Logger(subLoggerConfig => subLoggerConfig + .MinimumLevel.Information() + // .MinimumLevel.Override() is not supported for sub-loggers, even though the docs don't specify this. See https://github.com/serilog/serilog/pull/1033 + .Filter.ByExcluding("SourceContext like 'Microsoft.%' and @l in ['Information', 'Debug', 'Verbose']") + .WriteTo.Console() + .WriteTo.File(Path.Combine("logs", "{Date}.log"), rollingInterval: RollingInterval.Day)) + .WriteTo.File( + new RenderedCompactJsonFormatter(), + Path.Combine("logs", "{Date}.clef"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 2); + + var seqEndpoint = modixConfig.SeqEndpoint; + var seqKey = modixConfig.SeqKey; + + if (seqEndpoint != null && seqKey == null) // seq is enabled without a key + { + lc.WriteTo.Seq(seqEndpoint); + } + else if (seqEndpoint != null && seqKey != null) //seq is enabled with a key + { + lc.WriteTo.Seq(seqEndpoint, apiKey: seqKey); + } + + var webhookId = modixConfig.LogWebhookId; + var webhookToken = modixConfig.LogWebhookToken; + if (webhookId.HasValue && webhookToken != null) + { + lc + .WriteTo.DiscordWebhookSink(webhookId.Value, webhookToken, LogEventLevel.Error, new Lazy(sp.GetRequiredService)); + } + }); + + builder.Services.AddServices(Assembly.GetExecutingAssembly(), configuration); + + builder.Services.Configure(configuration); + + builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(@"dataprotection")); + + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/api/unauthorized"; + //options.LogoutPath = "/logout"; + options.ExpireTimeSpan = new TimeSpan(7, 0, 0, 0); + }) + .AddDiscordAuthentication(); + + builder.Services.AddAntiforgery(options => options.HeaderName = "X-XSRF-TOKEN"); + builder.Services.AddResponseCompression(); + + builder.Services.AddTransient, StaticFilesConfiguration>(); + builder.Services.AddTransient(); + + builder.Services + .AddServices(typeof(ModixContext).Assembly, configuration) + .AddNpgsql(configuration.GetValue(nameof(ModixConfig.DbConnection))); + + builder.Services + .AddModixHttpClients() + .AddModix(configuration); + + builder.Services.AddMvc(d => d.EnableEndpointRouting = false) + .AddNewtonsoftJson(options => + { + options.SerializerSettings.Converters.Add(new StringEnumConverter()); + options.SerializerSettings.Converters.Add(new StringULongConverter()); + }); + } + + public static void ConfigureVueServices(IServiceCollection services) + { + services.AddMvc(d => d.EnableEndpointRouting = false) + .AddNewtonsoftJson(options => + { + options.SerializerSettings.Converters.Add(new StringEnumConverter()); + options.SerializerSettings.Converters.Add(new StringULongConverter()); + }); + } + + public static void ConfigureVueApplication(WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + //Map to static files when not hitting the API + app.MapWhen(x => !x.Request.Path.Value.StartsWith("/api"), builder => + { + //Tiny middleware to redirect invalid requests to index.html, + //this ensures that our frontend routing works on fresh requests + builder.Use(async (context, next) => { - webBuilder - .UseConfiguration(config) - .UseStartup(); + await next(); + if (context.Response.StatusCode == 404 && !Path.HasExtension(context.Request.Path.Value)) + { + context.Request.Path = "/index.html"; + await next(); + } }) - .UseSerilog(); + .UseDefaultFiles() + .UseStaticFiles(); + }); + + //Defer to MVC for anything that doesn't match (and ostensibly + //starts with /api) + app.UseMvcWithDefaultRoute(); + } + + public static void ConfigureCommon(WebApplication app) + { + const string logFilesRequestPath = "/logfiles"; + + var options = new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto + }; + + app.UseForwardedHeaders(options); + + app.UseAuthentication(); + app.UseResponseCompression(); + + //Static redirect for invite link + app.Map("/invite", builder => + { + builder.Run(handler => + { + //TODO: Maybe un-hardcode this? + //handler.Response.StatusCode = StatusCodes + + handler.Response.Redirect("https://aka.ms/csharp-discord"); + return Task.CompletedTask; + }); + }); + + // Serve up log files for maintainers only + app.MapWhen(x => x.Request.Path.Value.StartsWith(logFilesRequestPath), builder => + { + var fileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "logs")); + builder + .UseMiddleware() + .UseDirectoryBrowser(new DirectoryBrowserOptions() + { + FileProvider = fileProvider, + RequestPath = logFilesRequestPath + }) + .UseStaticFiles(new StaticFileOptions() + { + FileProvider = fileProvider, + RequestPath = logFilesRequestPath, + ServeUnknownFileTypes = true + }); + }); + } } } diff --git a/Modix/Startup.cs b/Modix/Startup.cs deleted file mode 100644 index 10aa35bf3..000000000 --- a/Modix/Startup.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System; -using System.IO; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; -using Modix.Authentication; -using Modix.Configuration; -using Modix.Data; -using Modix.Data.Models.Core; -using Modix.Services.CodePaste; -using Newtonsoft.Json.Converters; -using Serilog; - -namespace Modix -{ - public class Startup - { - private const string _logFilesRequestPath - = "/logfiles"; - - private readonly IConfiguration _configuration; - - public Startup(IConfiguration configuration) - { - _configuration = configuration; - Log.Information("Configuration loaded. ASP.NET Startup is a go."); - } - - public void ConfigureServices(IServiceCollection services) - { - services.AddServices(Assembly.GetExecutingAssembly(), _configuration); - - services.Configure(_configuration); - - services.AddDataProtection() - .PersistKeysToFileSystem(new DirectoryInfo(@"dataprotection")); - - services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => - { - options.LoginPath = "/api/unauthorized"; - //options.LogoutPath = "/logout"; - options.ExpireTimeSpan = new TimeSpan(7, 0, 0, 0); - }) - .AddDiscordAuthentication(); - - services.AddAntiforgery(options => options.HeaderName = "X-XSRF-TOKEN"); - services.AddResponseCompression(); - - services.AddTransient, StaticFilesConfiguration>(); - services.AddTransient(); - - services - .AddServices(typeof(ModixContext).Assembly, _configuration) - .AddNpgsql(_configuration.GetValue(nameof(ModixConfig.DbConnection))); - - services - .AddModixHttpClients() - .AddModix(_configuration); - - services.AddMvc(d => d.EnableEndpointRouting = false) - .AddNewtonsoftJson(options => - { - options.SerializerSettings.Converters.Add(new StringEnumConverter()); - options.SerializerSettings.Converters.Add(new StringULongConverter()); - }); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IHostEnvironment env) - { - var options = new ForwardedHeadersOptions - { - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto - }; - - app.UseForwardedHeaders(options); - - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseAuthentication(); - app.UseResponseCompression(); - - //Static redirect for invite link - app.Map("/invite", builder => - { - builder.Run(handler => - { - //TODO: Maybe un-hardcode this? - //handler.Response.StatusCode = StatusCodes - - handler.Response.Redirect("https://aka.ms/csharp-discord"); - return Task.CompletedTask; - }); - }); - - // Serve up log files for maintainers only - app.MapWhen(x => x.Request.Path.Value.StartsWith(_logFilesRequestPath), builder => - { - var fileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "logs")); - builder - .UseMiddleware() - .UseDirectoryBrowser(new DirectoryBrowserOptions() - { - FileProvider = fileProvider, - RequestPath = _logFilesRequestPath - }) - .UseStaticFiles(new StaticFileOptions() - { - FileProvider = fileProvider, - RequestPath = _logFilesRequestPath, - ServeUnknownFileTypes = true - }); - }); - - //Map to static files when not hitting the API - app.MapWhen(x => !x.Request.Path.Value.StartsWith("/api"), builder => - { - //Tiny middleware to redirect invalid requests to index.html, - //this ensures that our frontend routing works on fresh requests - builder.Use(async (context, next) => - { - await next(); - if (context.Response.StatusCode == 404 && !Path.HasExtension(context.Request.Path.Value)) - { - context.Request.Path = "/index.html"; - await next(); - } - }) - .UseDefaultFiles() - .UseStaticFiles(); - }); - - //Defer to MVC for anything that doesn't match (and ostensibly - //starts with /api) - app.UseMvcWithDefaultRoute(); - } - } -} diff --git a/Modix/developmentSettings.default.json b/Modix/developmentSettings.default.json index 8f8713300..6876844d4 100644 --- a/Modix/developmentSettings.default.json +++ b/Modix/developmentSettings.default.json @@ -9,5 +9,6 @@ "MessageCacheSize": "", "ReplUrl": "", "IlUrl": "", - "WebsiteBaseUrl": "" + "WebsiteBaseUrl": "", + "UseBlazor": true } diff --git a/docker-stack.yml b/docker-stack.yml index 4d88c0bfb..b377ed770 100644 --- a/docker-stack.yml +++ b/docker-stack.yml @@ -77,6 +77,7 @@ services: MODIX_ReplUrl: http://repl:31337/eval MODIX_IlUrl: http://repl:31337/il MODIX_SeqEndpoint: http://seq:5341 + MODIX_UseBlazor: 'True' deploy: mode: replicated replicas: 1