diff --git a/ReplayBrowser/Helpers/ReplayHelper.cs b/ReplayBrowser/Helpers/ReplayHelper.cs index ba79e37..f3f074e 100644 --- a/ReplayBrowser/Helpers/ReplayHelper.cs +++ b/ReplayBrowser/Helpers/ReplayHelper.cs @@ -14,6 +14,8 @@ namespace ReplayBrowser.Helpers; public class ReplayHelper { + const string REDACTION_MESSAGE = "The account you are trying to search for is private or deleted. This might happen for various reasons as chosen by the account owner or the site administrative decision"; + private readonly IMemoryCache _cache; private readonly ReplayDbContext _context; private readonly AccountService _accountService; @@ -63,11 +65,9 @@ public async Task GetPlayerProfile(Guid playerGuid, Authent { var accountCaller = await _accountService.GetAccount(authenticationState); - var isGdpr = _context.GdprRequests.Any(g => g.Guid == playerGuid); + var isGdpr = await _context.GdprRequests.AnyAsync(g => g.Guid == playerGuid); if (isGdpr) - { - throw new UnauthorizedAccessException("This account is protected by a GDPR request. There is no data available."); - } + throw new UnauthorizedAccessException(REDACTION_MESSAGE); var accountRequested = _context.Accounts .Include(a => a.Settings) @@ -75,28 +75,8 @@ public async Task GetPlayerProfile(Guid playerGuid, Authent if (!skipPermsCheck) { - if (accountRequested is { Settings.RedactInformation: true }) - { - if (accountCaller == null || !accountCaller.IsAdmin) - { - if (accountCaller?.Guid != playerGuid) - { - if (accountRequested.Protected) - { - throw new UnauthorizedAccessException("This account is protected and redacted. This might happens due to harassment or other reasons."); - } - else - { - throw new UnauthorizedAccessException( - "The account you are trying to view is private. Contact the account owner and ask them to make their account public."); - } - } - } - } - } + CheckAccountAccess(caller: accountCaller, found: accountRequested); - if (!skipPermsCheck) - { await _accountService.AddHistory(accountCaller, new HistoryEntry() { Action = Enum.GetName(typeof(Action), Action.ProfileViewed) ?? "Unknown", @@ -271,37 +251,7 @@ public async Task SearchReplays(List searchItems, .Include(a => a.Settings) .FirstOrDefault(a => a.Username.ToLower().Equals(query.ToLower())); - if (callerAccount != null) - { - if (!callerAccount.Username.ToLower().Equals(query, StringComparison.OrdinalIgnoreCase)) - { - if (foundOocAccount != null && foundOocAccount.Settings.RedactInformation) - { - if (callerAccount == null || !callerAccount.IsAdmin) - { - if (foundOocAccount.Protected) - { - throw new UnauthorizedAccessException("This account is protected and redacted. This might happens due to harassment or other reasons."); - } - else - { - throw new UnauthorizedAccessException("The account you are trying to search for is private. Contact the account owner and ask them to make their account public."); - } - } - } - } - } else if (foundOocAccount != null && foundOocAccount.Settings.RedactInformation) - { - if (foundOocAccount.Protected) - { - throw new UnauthorizedAccessException("This account is protected and redacted. This might happens due to harassment or other reasons."); - } - else - { - throw new UnauthorizedAccessException( - "The account you are trying to search for is private. Contact the account owner and ask them to make their account public."); - } - } + CheckAccountAccess(caller: callerAccount, found: foundOocAccount); } foreach (var searchQueryItem in searchItems.Where(x => x.SearchModeEnum == SearchMode.Guid)) @@ -310,52 +260,10 @@ public async Task SearchReplays(List searchItems, var foundGuidAccount = _context.Accounts .Include(a => a.Settings) + // This .ToLower & .Contains trick allows for partially matching against a GUID .FirstOrDefault(a => a.Guid.ToString().ToLower().Contains(query.ToLower())); - if (foundGuidAccount != null && foundGuidAccount.Settings.RedactInformation) - { - if (callerAccount != null) - { - if (callerAccount.Guid != foundGuidAccount.Guid) - { - // if the requestor is not the found account and the requestor is not an admin, deny access - if (callerAccount == null || !callerAccount.IsAdmin) - { - if (foundGuidAccount.Protected) - { - throw new UnauthorizedAccessException("This account is protected and redacted. This might happens due to harassment or other reasons."); - } - else - { - throw new UnauthorizedAccessException( - "The account you are trying to search for is private. Contact the account owner and ask them to make their account public."); - } - } - } - } else - { - if (foundGuidAccount.Protected) - { - throw new UnauthorizedAccessException("This account is protected and redacted. This might happens due to harassment or other reasons."); - } - else - { - throw new UnauthorizedAccessException( - "The account you are trying to search for is private. Contact the account owner and ask them to make their account public."); - } - } - } else if (foundGuidAccount != null && foundGuidAccount.Settings.RedactInformation) - { - if (foundGuidAccount.Protected) - { - throw new UnauthorizedAccessException("This account is protected and redacted. This might happens due to harassment or other reasons."); - } - else - { - throw new UnauthorizedAccessException( - "The account you are trying to search for is private. Contact the account owner and ask them to make their account public."); - } - } + CheckAccountAccess(caller: callerAccount, found: foundGuidAccount); } // "Execution of the current method continues before the call is completed" is a desired outcome here @@ -366,7 +274,7 @@ public async Task SearchReplays(List searchItems, { Action = Enum.GetName(typeof(Action), Action.SearchPerformed) ?? "Unknown", Time = DateTime.UtcNow, - Details = string.Join(", ", searchItems.Select(x => $"{x.SearchMode}={x.SearchValue}")) + Details = string.Join(", ", searchItems.Select(x => $"{x.SearchModeEnum}={x.SearchValue}")) }); }); #pragma warning restore CS4014 @@ -384,6 +292,43 @@ public async Task SearchReplays(List searchItems, }; } + /// + /// Check whether the caller account (first arg) has access to view the found account (second arg) + /// + /// + /// I am really not a fan of two params of same type being used here. It can and probably will lead to confusing them around. + /// TODO: Investigate what's the diff between and + /// + public static void CheckAccountAccess(Account? caller, Account? found) + { + // There's no account to worry about yay + if (found is null) + return; + + // Is there any redaction to worry about? + if (!found.Settings.RedactInformation) + return; + // Ah shit + + // Not the person we're looking for + if (caller is null) + throw new UnauthorizedAccessException(REDACTION_MESSAGE); + + // Admins can see everything. Without this we could just peek into the DB. + if (caller.IsAdmin) + return; + + // Same person (or at least account), let them at it + if (caller.Guid == found.Guid) + return; + + // Catch-all + // Don't give more info about why, what, just use a generic message for everything + // For debugging you can always just check the logs or DB + // Giving specific info like "admin" vs "self redacted" vs "GDPR request" + throw new UnauthorizedAccessException(REDACTION_MESSAGE); + } + public async Task HasProfile(string username, AuthenticationState state) { var accountGuid = AccountHelper.GetAccountGuid(state); @@ -431,7 +376,7 @@ public async Task SearchReplays(List searchItems, var stopWatch = new Stopwatch(); stopWatch.Start(); - var cacheKey = $"{string.Join("-", searchItems.Select(x => $"{x.SearchMode}-{x.SearchValue}"))}"; + var cacheKey = $"{string.Join("-", searchItems.Select(x => $"{x.SearchModeEnum}-{x.SearchValue}"))}"; var queryable = _context.Replays .AsNoTracking() diff --git a/ReplayBrowser/Models/SearchMode.cs b/ReplayBrowser/Models/SearchMode.cs index 650a696..0e04a7a 100644 --- a/ReplayBrowser/Models/SearchMode.cs +++ b/ReplayBrowser/Models/SearchMode.cs @@ -1,14 +1,25 @@ -namespace ReplayBrowser.Models; +using System.ComponentModel.DataAnnotations; + +namespace ReplayBrowser.Models; public enum SearchMode { + [Display(Name = "Map")] Map, + [Display(Name = "Gamemode")] Gamemode, + [Display(Name = "Server ID")] ServerId, + [Display(Name = "Round End Text")] RoundEndText, + [Display(Name = "Player IC Name")] PlayerIcName, + [Display(Name = "Player OOC Name")] PlayerOocName, + [Display(Name = "Player GUID")] Guid, + [Display(Name = "Server Name")] ServerName, + [Display(Name = "Round ID")] RoundId } diff --git a/ReplayBrowser/Models/SearchQueryItem.cs b/ReplayBrowser/Models/SearchQueryItem.cs index feb4703..42fe614 100644 --- a/ReplayBrowser/Models/SearchQueryItem.cs +++ b/ReplayBrowser/Models/SearchQueryItem.cs @@ -1,31 +1,85 @@ using System.Text.Json.Serialization; +using Microsoft.Extensions.Primitives; namespace ReplayBrowser.Models; public class SearchQueryItem { [JsonPropertyName("searchMode")] - public required string SearchMode { get; set; } + public string SearchMode + { + set + { + if (!ModeMapping.TryGetValue(value.ToLower(), out var mapped)) + throw new ArgumentOutOfRangeException(); + SearchModeEnum = mapped; + } + } [JsonPropertyName("searchValue")] public required string SearchValue { get; set; } + [JsonIgnore] + public SearchMode SearchModeEnum { get; set; } - public SearchMode SearchModeEnum - { - get + public static List FromQuery(IQueryCollection query) { + List result = []; + // Yes this is fragile. No it won't really do anything but annoy people + // Technically inefficient. In practice, meh + // Too bad this collection isn't just a list of tuples + var ordered = query.OrderBy(q => q.Key.Contains('[') ? int.Parse(q.Key[(q.Key.IndexOf('[') + 1)..q.Key.IndexOf(']')]) : int.MaxValue).ToList(); + + foreach (var item in ordered) { - return SearchMode switch - { - "Map" => Models.SearchMode.Map, - "Gamemode" => Models.SearchMode.Gamemode, - "Server id" => Models.SearchMode.ServerId, - "Round end text" => Models.SearchMode.RoundEndText, - "Player ic name" => Models.SearchMode.PlayerIcName, - "Player ooc name" => Models.SearchMode.PlayerOocName, - "Guid" => Models.SearchMode.Guid, - "Server name" => Models.SearchMode.ServerName, - "Round id" => Models.SearchMode.RoundId, - _ => throw new ArgumentOutOfRangeException() - }; + var index = item.Key.IndexOf('['); + if (index != -1) + result.AddRange(QueryValueParse(item.Key[..index], item.Value)); + else + result.AddRange(QueryValueParse(item.Key, item.Value)); } + + var legacyQuery = query["searches"]; + if (legacyQuery.Count > 0 && legacyQuery[0]!.Length > 0) + result.AddRange(FromQueryLegacy(legacyQuery[0]!)); + + return result; } + + public static List FromQueryLegacy(string searchesParam) { + var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(searchesParam)); + return System.Text.Json.JsonSerializer.Deserialize>(decoded)!; + } + + public static List QueryValueParse(string key, StringValues values) { + if (!ModeMapping.TryGetValue(key, out var type)) + return []; + + return values + .Where(v => v is not null && v.Length > 0) + .Select(v => new SearchQueryItem { SearchModeEnum = type, SearchValue = v! }) + .ToList(); + } + + public static string QueryName(SearchMode mode) + => ModeMapping.First(v => v.Value == mode).Key; + + // String values must be lowercase! + // Be careful with changing any of the values here, as it can cause old searched to be invalid + // For this reason, it's better to only add new entries + public static readonly Dictionary ModeMapping = new() { + { "guid", Models.SearchMode.Guid }, + { "username", Models.SearchMode.PlayerOocName }, + { "character", Models.SearchMode.PlayerIcName }, + { "server_id", Models.SearchMode.ServerId }, + { "server", Models.SearchMode.ServerName }, + { "round", Models.SearchMode.RoundId }, + { "map", Models.SearchMode.Map }, + { "gamemode", Models.SearchMode.Gamemode }, + { "endtext", Models.SearchMode.RoundEndText }, + // Legacy + { "player ooc name", Models.SearchMode.PlayerOocName }, + { "player ic name", Models.SearchMode.PlayerIcName }, + { "server id", Models.SearchMode.ServerId }, + { "server name", Models.SearchMode.ServerName }, + { "round id", Models.SearchMode.RoundId }, + { "round end text", Models.SearchMode.RoundEndText }, + }; } \ No newline at end of file diff --git a/ReplayBrowser/Pages/Search.razor b/ReplayBrowser/Pages/Search.razor index 3d81d2f..93dcd1d 100644 --- a/ReplayBrowser/Pages/Search.razor +++ b/ReplayBrowser/Pages/Search.razor @@ -3,6 +3,7 @@ @using System.Diagnostics @using Humanizer @using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Mvc @using ReplayBrowser.Data.Models @using ReplayBrowser.Models @using ReplayBrowser.Pages.Shared @@ -10,15 +11,16 @@ @using ReplayBrowser.Data @using ReplayBrowser.Helpers -@inject NavigationManager NavigationManager @inject AuthenticationStateProvider AuthenticationStateProvider +@inject IHttpContextAccessor HttpContextAccessor +@inject NavigationManager NavigationManager @inject ReplayHelper ReplayHelper Replay viewer

Replay browser for Space Station 14

Search for replays by using the search bar below


- +
@if (ErrorMessage != null) { @@ -149,19 +151,19 @@ public List SearchItems { get; set; } = new List(); private string OpenGraphDescriptionError => ErrorMessage + "\n" + ErrorDetails; - private string OpenGraphDescriptionResults => SearchResult.TotalReplays + " found. Page: " + (SearchResult.CurrentPage + 1) + "\n" + FormatOpenGraphSpeciferDescription(); - private string OpenGraphDescriptionNoResults => "No results found.\n" + FormatOpenGraphSpeciferDescription(); + private string OpenGraphDescriptionResults => $"Found {SearchResult.TotalReplays} replays. Page: {SearchResult.CurrentPage + 1}/{SearchResult.PageCount}\n\n{FormatOpenGraphSpeciferDescription()}"; + private string OpenGraphDescriptionNoResults => "No results found.\n\n" + FormatOpenGraphSpeciferDescription(); private string FormatOpenGraphSpeciferDescription() { - var searchQuery = string.Join(", ", SearchItems.Select(x => x.SearchMode + ": " + x.SearchValue)); + var searchQuery = string.Join("\n", SearchItems.Select(x => $"- {x.SearchModeEnum.Humanize()}: {x.SearchValue}")); // Limit the length of the description to 200 characters if (searchQuery.Length > 200) { searchQuery = searchQuery.Substring(0, 200) + "..."; } - return $"Search results for {searchQuery}"; + return $"Searching for:\n{searchQuery}"; } /// /// Search modes that you can only once in a query to save on processing time @@ -175,39 +177,28 @@ protected override async Task OnInitializedAsync() { stopWatch.Start(); - // Get mode and query from query string - var uri = new Uri(NavigationManager.Uri); - var query = uri.Query; - var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); - if (query.Length <= 1) - { - return; - } - var queryDict = System.Web.HttpUtility.ParseQueryString(query.Substring(1)); - var searches = queryDict.Get("searches"); - var page = queryDict.Get("page") ?? "0"; - var pageInt = int.Parse(page); + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); - if (searches == null) - { - ErrorMessage = "Invalid search query"; - stopWatch.Stop(); + // Pull a nice multi-value query interface from the Request + // TODO: Why can we not access this in the component? + var queryTest = HttpContextAccessor.HttpContext?.Request.Query; + if (queryTest is null || queryTest.Count < 1) return; - } - // Searches is a base64 encoded json string + var pageInt = int.Parse(queryTest["page"].FirstOrDefault() ?? "0"); + try { - var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(searches)); - SearchItems = System.Text.Json.JsonSerializer.Deserialize>(decoded)!; + // Get the search params from query + // Using both multi-value plain search, and legacy Base64 JSON + SearchItems = SearchQueryItem.FromQuery(queryTest); } catch (Exception e) { ErrorMessage = "Invalid search query"; ErrorDetails = e.Message; stopWatch.Stop(); - NavigationManager.NavigateTo("/"); return; } @@ -242,9 +233,9 @@ ErrorMessage = "Invalid search query"; ErrorDetails = "No search items found"; stopWatch.Stop(); - NavigationManager.NavigateTo("/"); return; } + if (SearchItems.Exists(x => x.SearchModeEnum == SearchMode.PlayerOocName)) { ProfileFound = await ReplayHelper.HasProfile(SearchItems.Find(x => x.SearchModeEnum == SearchMode.PlayerOocName)!.SearchValue, authState); diff --git a/ReplayBrowser/Pages/Shared/SearchBar.razor b/ReplayBrowser/Pages/Shared/SearchBar.razor index 2527f77..479ee19 100644 --- a/ReplayBrowser/Pages/Shared/SearchBar.razor +++ b/ReplayBrowser/Pages/Shared/SearchBar.razor @@ -1,29 +1,37 @@ @using Humanizer +@using Microsoft.AspNetCore.Mvc.ViewFeatures @using Microsoft.Extensions.Configuration @using ReplayBrowser.Data @using ReplayBrowser.Models @inject IConfiguration Configuration +@code{ + [Parameter] public List? Items { get; set; } +} + @{ var searchModes = Enum.GetValues(typeof(SearchMode)).Cast().ToList(); -