Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
{
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