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 000000000..80429cc0b Binary files /dev/null and b/Modix.Web/wwwroot/favicon.ico differ 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