Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Search de-base64ed #74

Merged
merged 5 commits into from
Nov 30, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
{
static readonly 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";
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be made a const.


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 && !found.Protected)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Protected accounts can still change their redaction settings and the only thing .Protected does is display a message on the home page. This should not be checked for redaction status.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW does that keep showing up even when redaction is on?

Also need to eventually clarify in UI and code as to which combo deletes stuff and which just hides it from public view.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. It will keep showing up, even if your settings are changed. Only way to remove it is to edit the DB.

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