Foxnouns.NET/Foxnouns.Backend/Utils/ValidationUtils.cs

206 lines
No EOL
7.9 KiB
C#

using System.Text.RegularExpressions;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
namespace Foxnouns.Backend.Utils;
/// <summary>
/// Static methods for validating user input (mostly making sure it's not too short or too long)
/// </summary>
public static class ValidationUtils
{
private static readonly Regex UsernameRegex = new("^[\\w-.]{2,40}$", RegexOptions.IgnoreCase);
private static readonly Regex MemberRegex =
new("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,\\*]{1,100}$", RegexOptions.IgnoreCase);
private static readonly string[] InvalidUsernames =
[
"..",
"admin",
"administrator",
"mod",
"moderator",
"api",
"page",
"pronouns",
"settings",
"pronouns.cc",
"pronounscc"
];
private static readonly string[] InvalidMemberNames =
[
// these break routing outright
".",
"..",
// the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible
"edit"
];
public static ValidationError? ValidateUsername(string username)
{
if (!UsernameRegex.IsMatch(username))
return username.Length switch
{
< 2 => ValidationError.LengthError("Username is too short", 2, 40, username.Length),
> 40 => ValidationError.LengthError("Username is too long", 2, 40, username.Length),
_ => ValidationError.GenericValidationError(
"Username is invalid, can only contain alphanumeric characters, dashes, underscores, and periods",
username)
};
if (InvalidUsernames.Any(u => string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase)))
return ValidationError.GenericValidationError("Username is not allowed", username);
return null;
}
public static ValidationError? ValidateMemberName(string memberName)
{
if (!UsernameRegex.IsMatch(memberName))
return memberName.Length switch
{
< 2 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
> 40 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length),
_ => ValidationError.GenericValidationError(
"Member name cannot contain any of the following: " +
" @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , " +
"and cannot be one or two periods",
memberName)
};
if (InvalidMemberNames.Any(u => string.Equals(u, memberName, StringComparison.InvariantCultureIgnoreCase)))
return ValidationError.GenericValidationError("Name is not allowed", memberName);
return null;
}
public static void Validate(IEnumerable<(string, ValidationError?)> errors)
{
errors = errors.Where(e => e.Item2 != null).ToList();
if (!errors.Any()) return;
var errorDict = new Dictionary<string, IEnumerable<ValidationError>>();
foreach (var error in errors)
{
if (errorDict.TryGetValue(error.Item1, out var value)) errorDict[error.Item1] = value.Append(error.Item2!);
errorDict.Add(error.Item1, [error.Item2!]);
}
throw new ApiError.BadRequest("Error validating input", errorDict);
}
public static ValidationError? ValidateDisplayName(string? displayName)
{
return displayName?.Length switch
{
0 => ValidationError.LengthError("Display name is too short", 1, 100, displayName.Length),
> 100 => ValidationError.LengthError("Display name is too long", 1, 100, displayName.Length),
_ => null
};
}
public static ValidationError? ValidateBio(string? bio)
{
return bio?.Length switch
{
0 => ValidationError.LengthError("Bio is too short", 1, 1024, bio.Length),
> 1024 => ValidationError.LengthError("Bio is too long", 1, 1024, bio.Length),
_ => null
};
}
public static ValidationError? ValidateAvatar(string? avatar)
{
return avatar?.Length switch
{
0 => ValidationError.GenericValidationError("Avatar cannot be empty", null),
> 1_500_000 => ValidationError.GenericValidationError("Avatar is too large", null),
_ => null
};
}
private const int FieldLimit = 25;
private const int FieldNameLimit = 100;
private const int FieldEntryTextLimit = 100;
private const int FieldEntriesLimit = 100;
private static readonly string[] DefaultStatusOptions =
[
"favourite",
"okay",
"jokingly",
"friends_only",
"avoid"
];
public static IEnumerable<(string, ValidationError?)> ValidateFields(List<Field>? fields,
IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences)
{
if (fields == null) return [];
var errors = new List<(string, ValidationError?)>();
if (fields.Count > 25)
errors.Add(("fields", ValidationError.LengthError("Too many fields", 0, FieldLimit, fields.Count)));
// No overwhelming this function, thank you
if (fields.Count > 100) return errors;
foreach (var (field, index) in fields.Select((field, index) => (field, index)))
{
switch (field.Name.Length)
{
case > FieldNameLimit:
errors.Add(($"fields.{index}.name",
ValidationError.LengthError("Field name is too long", 1, FieldNameLimit, field.Name.Length)));
break;
case < 1:
errors.Add(($"fields.{index}.name",
ValidationError.LengthError("Field name is too short", 1, FieldNameLimit, field.Name.Length)));
break;
}
errors = errors.Concat(ValidateFieldEntries(field.Entries, customPreferences, $"fields.{index}")).ToList();
}
return errors;
}
public static IEnumerable<(string, ValidationError?)> ValidateFieldEntries(FieldEntry[]? entries,
IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences, string errorPrefix = "fields")
{
if (entries == null || entries.Length == 0) return [];
var errors = new List<(string, ValidationError?)>();
if (entries.Length > FieldEntriesLimit)
errors.Add(($"{errorPrefix}.entries",
ValidationError.LengthError("Field has too many entries", 0, FieldEntriesLimit,
entries.Length)));
// Same as above, no overwhelming this function with a ridiculous amount of entries
if (entries.Length > FieldEntriesLimit + 50) return errors;
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
{
switch (entry.Value.Length)
{
case > FieldEntryTextLimit:
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
ValidationError.LengthError("Field value is too long", 1, FieldEntryTextLimit,
entry.Value.Length)));
break;
case < 1:
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
ValidationError.LengthError("Field value is too short", 1, FieldEntryTextLimit,
entry.Value.Length)));
break;
}
var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? [];
if (!DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status))
errors.Add(($"{errorPrefix}.entries.{entryIdx}.status",
ValidationError.GenericValidationError("Invalid status", entry.Status)));
}
return errors;
}
}