Skip to content

Commit

Permalink
Search de-base64ed (#74)
Browse files Browse the repository at this point in the history
* Clean up redacted access checking

Also 1984 out user facing distinction between user redacted, admin redacted and GDPR request redacted accounts

* Implement better handling of search query parameters

* Do the frontend handling of search

* Fix the empty search

* Fix feedback issues
  • Loading branch information
SaphireLattice authored Nov 30, 2024
1 parent a771327 commit fb62642
Show file tree
Hide file tree
Showing 6 changed files with 272 additions and 308 deletions.
149 changes: 47 additions & 102 deletions ReplayBrowser/Helpers/ReplayHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -63,40 +65,18 @@ public async Task<CollectedPlayerData> 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)
.FirstOrDefault(a => a.Guid == playerGuid);

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",
Expand Down Expand Up @@ -271,37 +251,7 @@ public async Task<SearchResult> SearchReplays(List<SearchQueryItem> 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))
Expand All @@ -310,52 +260,10 @@ public async Task<SearchResult> SearchReplays(List<SearchQueryItem> 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
Expand All @@ -366,7 +274,7 @@ public async Task<SearchResult> SearchReplays(List<SearchQueryItem> 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
Expand All @@ -384,6 +292,43 @@ public async Task<SearchResult> SearchReplays(List<SearchQueryItem> searchItems,
};
}

/// <summary>
/// Check whether the caller account (first arg) has access to view the found account (second arg)
/// </summary>
/// <remarks>
/// 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 <see cref="AccountSettings.RedactInformation"/> and <see cref="Account.Protected"/>
/// </remarks>
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<PlayerData?> HasProfile(string username, AuthenticationState state)
{
var accountGuid = AccountHelper.GetAccountGuid(state);
Expand Down Expand Up @@ -431,7 +376,7 @@ public async Task<SearchResult> SearchReplays(List<SearchQueryItem> 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()
Expand Down
13 changes: 12 additions & 1 deletion ReplayBrowser/Models/SearchMode.cs
Original file line number Diff line number Diff line change
@@ -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
}
88 changes: 71 additions & 17 deletions ReplayBrowser/Models/SearchQueryItem.cs
Original file line number Diff line number Diff line change
@@ -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<SearchQueryItem> FromQuery(IQueryCollection query) {
List<SearchQueryItem> 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<SearchQueryItem> FromQueryLegacy(string searchesParam) {
var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(searchesParam));
return System.Text.Json.JsonSerializer.Deserialize<List<SearchQueryItem>>(decoded)!;
}

public static List<SearchQueryItem> 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<string, SearchMode> 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 },
};
}
Loading

0 comments on commit fb62642

Please sign in to comment.