feat(backend): validate custom preferences on save
This commit is contained in:
		
							parent
							
								
									71b59dbb00
								
							
						
					
					
						commit
						8b1d5b2c1b
					
				
					 6 changed files with 560 additions and 477 deletions
				
			
		|  | @ -27,7 +27,7 @@ public class MetaController : ApiControllerBase | |||
|                 new Limits( | ||||
|                     MemberCount: MembersController.MaxMemberCount, | ||||
|                     BioLength: ValidationUtils.MaxBioLength, | ||||
|                     CustomPreferences: UsersController.MaxCustomPreferences | ||||
|                     CustomPreferences: ValidationUtils.MaxCustomPreferences | ||||
|                 ) | ||||
|             ) | ||||
|         ); | ||||
|  |  | |||
|  | @ -197,11 +197,11 @@ public class UsersController( | |||
|     [Authorize("user.update")] | ||||
|     [ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)] | ||||
|     public async Task<IActionResult> UpdateCustomPreferencesAsync( | ||||
|         [FromBody] List<CustomPreferencesUpdateRequest> req, | ||||
|         [FromBody] List<CustomPreferenceUpdate> req, | ||||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     { | ||||
|         ValidationUtils.Validate(ValidateCustomPreferences(req)); | ||||
|         ValidationUtils.Validate(ValidationUtils.ValidateCustomPreferences(req)); | ||||
| 
 | ||||
|         var user = await db.ResolveUserAsync(CurrentUser!.Id, ct); | ||||
|         var preferences = user | ||||
|  | @ -241,7 +241,7 @@ public class UsersController( | |||
|     } | ||||
| 
 | ||||
|     [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] | ||||
|     public class CustomPreferencesUpdateRequest | ||||
|     public class CustomPreferenceUpdate | ||||
|     { | ||||
|         public Snowflake? Id { get; init; } | ||||
|         public required string Icon { get; set; } | ||||
|  | @ -251,34 +251,6 @@ public class UsersController( | |||
|         public bool Favourite { get; set; } | ||||
|     } | ||||
| 
 | ||||
|     public const int MaxCustomPreferences = 25; | ||||
| 
 | ||||
|     private static List<(string, ValidationError?)> ValidateCustomPreferences( | ||||
|         List<CustomPreferencesUpdateRequest> preferences | ||||
|     ) | ||||
|     { | ||||
|         var errors = new List<(string, ValidationError?)>(); | ||||
| 
 | ||||
|         if (preferences.Count > MaxCustomPreferences) | ||||
|             errors.Add( | ||||
|                 ( | ||||
|                     "custom_preferences", | ||||
|                     ValidationError.LengthError( | ||||
|                         "Too many custom preferences", | ||||
|                         0, | ||||
|                         MaxCustomPreferences, | ||||
|                         preferences.Count | ||||
|                     ) | ||||
|                 ) | ||||
|             ); | ||||
|         if (preferences.Count > 50) | ||||
|             return errors; | ||||
| 
 | ||||
|         // TODO: validate individual preferences | ||||
| 
 | ||||
|         return errors; | ||||
|     } | ||||
| 
 | ||||
|     public class UpdateUserRequest : PatchRequest | ||||
|     { | ||||
|         public string? Username { get; init; } | ||||
|  |  | |||
							
								
								
									
										287
									
								
								Foxnouns.Backend/Utils/ValidationUtils.Fields.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								Foxnouns.Backend/Utils/ValidationUtils.Fields.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,287 @@ | |||
| // Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) | ||||
| // | ||||
| // This program is free software: you can redistribute it and/or modify | ||||
| // it under the terms of the GNU Affero General Public License as published | ||||
| // by the Free Software Foundation, either version 3 of the License, or | ||||
| // (at your option) any later version. | ||||
| // | ||||
| // This program is distributed in the hope that it will be useful, | ||||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
| // GNU Affero General Public License for more details. | ||||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| 
 | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Utils; | ||||
| 
 | ||||
| public static partial class ValidationUtils | ||||
| { | ||||
|     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, | ||||
|                         Limits.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 > Limits.FieldNameLimit: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"fields.{index}.name", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Field name is too long", | ||||
|                                 1, | ||||
|                                 Limits.FieldNameLimit, | ||||
|                                 field.Name.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|                 case < 1: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"fields.{index}.name", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Field name is too short", | ||||
|                                 1, | ||||
|                                 Limits.FieldNameLimit, | ||||
|                                 field.Name.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|             } | ||||
| 
 | ||||
|             errors = errors | ||||
|                 .Concat( | ||||
|                     ValidateFieldEntries( | ||||
|                         field.Entries, | ||||
|                         customPreferences, | ||||
|                         $"fields.{index}.entries" | ||||
|                     ) | ||||
|                 ) | ||||
|                 .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 > Limits.FieldEntriesLimit) | ||||
|             errors.Add( | ||||
|                 ( | ||||
|                     errorPrefix, | ||||
|                     ValidationError.LengthError( | ||||
|                         "Field has too many entries", | ||||
|                         0, | ||||
|                         Limits.FieldEntriesLimit, | ||||
|                         entries.Length | ||||
|                     ) | ||||
|                 ) | ||||
|             ); | ||||
| 
 | ||||
|         // Same as above, no overwhelming this function with a ridiculous amount of entries | ||||
|         if (entries.Length > Limits.FieldEntriesLimit + 50) | ||||
|             return errors; | ||||
| 
 | ||||
|         foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) | ||||
|         { | ||||
|             switch (entry.Value.Length) | ||||
|             { | ||||
|                 case > Limits.FieldEntryTextLimit: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"{errorPrefix}.{entryIdx}.value", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Field value is too long", | ||||
|                                 1, | ||||
|                                 Limits.FieldEntryTextLimit, | ||||
|                                 entry.Value.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|                 case < 1: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"{errorPrefix}.{entryIdx}.value", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Field value is too short", | ||||
|                                 1, | ||||
|                                 Limits.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}.{entryIdx}.status", | ||||
|                         ValidationError.GenericValidationError("Invalid status", entry.Status) | ||||
|                     ) | ||||
|                 ); | ||||
|         } | ||||
| 
 | ||||
|         return errors; | ||||
|     } | ||||
| 
 | ||||
|     public static IEnumerable<(string, ValidationError?)> ValidatePronouns( | ||||
|         Pronoun[]? entries, | ||||
|         IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences, | ||||
|         string errorPrefix = "pronouns" | ||||
|     ) | ||||
|     { | ||||
|         if (entries == null || entries.Length == 0) | ||||
|             return []; | ||||
|         var errors = new List<(string, ValidationError?)>(); | ||||
| 
 | ||||
|         if (entries.Length > Limits.FieldEntriesLimit) | ||||
|             errors.Add( | ||||
|                 ( | ||||
|                     errorPrefix, | ||||
|                     ValidationError.LengthError( | ||||
|                         "Too many pronouns", | ||||
|                         0, | ||||
|                         Limits.FieldEntriesLimit, | ||||
|                         entries.Length | ||||
|                     ) | ||||
|                 ) | ||||
|             ); | ||||
| 
 | ||||
|         // Same as above, no overwhelming this function with a ridiculous amount of entries | ||||
|         if (entries.Length > Limits.FieldEntriesLimit + 50) | ||||
|             return errors; | ||||
| 
 | ||||
|         foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) | ||||
|         { | ||||
|             switch (entry.Value.Length) | ||||
|             { | ||||
|                 case > Limits.FieldEntryTextLimit: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"{errorPrefix}.{entryIdx}.value", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Pronoun value is too long", | ||||
|                                 1, | ||||
|                                 Limits.FieldEntryTextLimit, | ||||
|                                 entry.Value.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|                 case < 1: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"{errorPrefix}.{entryIdx}.value", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Pronoun value is too short", | ||||
|                                 1, | ||||
|                                 Limits.FieldEntryTextLimit, | ||||
|                                 entry.Value.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|             } | ||||
| 
 | ||||
|             if (entry.DisplayText != null) | ||||
|             { | ||||
|                 switch (entry.DisplayText.Length) | ||||
|                 { | ||||
|                     case > Limits.FieldEntryTextLimit: | ||||
|                         errors.Add( | ||||
|                             ( | ||||
|                                 $"{errorPrefix}.{entryIdx}.display_text", | ||||
|                                 ValidationError.LengthError( | ||||
|                                     "Pronoun display text is too long", | ||||
|                                     1, | ||||
|                                     Limits.FieldEntryTextLimit, | ||||
|                                     entry.Value.Length | ||||
|                                 ) | ||||
|                             ) | ||||
|                         ); | ||||
|                         break; | ||||
|                     case < 1: | ||||
|                         errors.Add( | ||||
|                             ( | ||||
|                                 $"{errorPrefix}.{entryIdx}.display_text", | ||||
|                                 ValidationError.LengthError( | ||||
|                                     "Pronoun display text is too short", | ||||
|                                     1, | ||||
|                                     Limits.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}.{entryIdx}.status", | ||||
|                         ValidationError.GenericValidationError("Invalid status", entry.Status) | ||||
|                     ) | ||||
|                 ); | ||||
|         } | ||||
| 
 | ||||
|         return errors; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										72
									
								
								Foxnouns.Backend/Utils/ValidationUtils.Preferences.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								Foxnouns.Backend/Utils/ValidationUtils.Preferences.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,72 @@ | |||
| // Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) | ||||
| // | ||||
| // This program is free software: you can redistribute it and/or modify | ||||
| // it under the terms of the GNU Affero General Public License as published | ||||
| // by the Free Software Foundation, either version 3 of the License, or | ||||
| // (at your option) any later version. | ||||
| // | ||||
| // This program is distributed in the hope that it will be useful, | ||||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
| // GNU Affero General Public License for more details. | ||||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| 
 | ||||
| using Foxnouns.Backend.Controllers; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Utils; | ||||
| 
 | ||||
| public static partial class ValidationUtils | ||||
| { | ||||
|     public const int MaxCustomPreferences = 25; | ||||
|     public const int MaxPreferenceTooltipLength = 128; | ||||
| 
 | ||||
|     public static List<(string, ValidationError?)> ValidateCustomPreferences( | ||||
|         List<UsersController.CustomPreferenceUpdate> preferences | ||||
|     ) | ||||
|     { | ||||
|         var errors = new List<(string, ValidationError?)>(); | ||||
| 
 | ||||
|         if (preferences.Count > MaxCustomPreferences) | ||||
|             errors.Add( | ||||
|                 ( | ||||
|                     "custom_preferences", | ||||
|                     ValidationError.LengthError( | ||||
|                         "Too many custom preferences", | ||||
|                         0, | ||||
|                         MaxCustomPreferences, | ||||
|                         preferences.Count | ||||
|                     ) | ||||
|                 ) | ||||
|             ); | ||||
|         if (preferences.Count > 50) | ||||
|             return errors; | ||||
| 
 | ||||
|         foreach (var (p, i) in preferences.Select((p, i) => (p, i))) | ||||
|         { | ||||
|             if (!BootstrapIcons.IsValid(p.Icon)) | ||||
|                 errors.Add( | ||||
|                     ( | ||||
|                         $"custom_preferences.{i}.icon", | ||||
|                         ValidationError.DisallowedValueError("Invalid icon name", [], p.Icon) | ||||
|                     ) | ||||
|                 ); | ||||
| 
 | ||||
|             if (p.Tooltip.Length is 1 or > MaxPreferenceTooltipLength) | ||||
|                 errors.Add( | ||||
|                     ( | ||||
|                         $"custom_preferences.{i}.tooltip", | ||||
|                         ValidationError.LengthError( | ||||
|                             "Tooltip is too short or too long", | ||||
|                             1, | ||||
|                             MaxPreferenceTooltipLength, | ||||
|                             p.Tooltip.Length | ||||
|                         ) | ||||
|                     ) | ||||
|                 ); | ||||
|         } | ||||
| 
 | ||||
|         return errors; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										197
									
								
								Foxnouns.Backend/Utils/ValidationUtils.Strings.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										197
									
								
								Foxnouns.Backend/Utils/ValidationUtils.Strings.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,197 @@ | |||
| // Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) | ||||
| // | ||||
| // This program is free software: you can redistribute it and/or modify | ||||
| // it under the terms of the GNU Affero General Public License as published | ||||
| // by the Free Software Foundation, either version 3 of the License, or | ||||
| // (at your option) any later version. | ||||
| // | ||||
| // This program is distributed in the hope that it will be useful, | ||||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
| // GNU Affero General Public License for more details. | ||||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| 
 | ||||
| using System.Text.RegularExpressions; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Utils; | ||||
| 
 | ||||
| public static partial class ValidationUtils | ||||
| { | ||||
|     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 (!MemberRegex().IsMatch(memberName)) | ||||
|             return memberName.Length switch | ||||
|             { | ||||
|                 < 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length), | ||||
|                 > 100 => 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 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, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     private const int MaxLinks = 25; | ||||
|     private const int MaxLinkLength = 256; | ||||
| 
 | ||||
|     public static IEnumerable<(string, ValidationError?)> ValidateLinks(string[]? links) | ||||
|     { | ||||
|         if (links == null) | ||||
|             return []; | ||||
|         if (links.Length > MaxLinks) | ||||
|             return | ||||
|             [ | ||||
|                 ("links", ValidationError.LengthError("Too many links", 0, MaxLinks, links.Length)), | ||||
|             ]; | ||||
| 
 | ||||
|         var errors = new List<(string, ValidationError?)>(); | ||||
|         foreach (var (link, idx) in links.Select((l, i) => (l, i))) | ||||
|         { | ||||
|             switch (link.Length) | ||||
|             { | ||||
|                 case 0: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"links.{idx}", | ||||
|                             ValidationError.LengthError("Link cannot be empty", 1, 256, 0) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|                 case > MaxLinkLength: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"links.{idx}", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Link is too long", | ||||
|                                 1, | ||||
|                                 MaxLinkLength, | ||||
|                                 link.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return errors; | ||||
|     } | ||||
| 
 | ||||
|     public const int MaxBioLength = 1024; | ||||
|     public const int MaxAvatarLength = 1_500_000; | ||||
| 
 | ||||
|     public static ValidationError? ValidateBio(string? bio) | ||||
|     { | ||||
|         return bio?.Length switch | ||||
|         { | ||||
|             0 => ValidationError.LengthError("Bio is too short", 1, MaxBioLength, bio.Length), | ||||
|             > MaxBioLength => ValidationError.LengthError( | ||||
|                 "Bio is too long", | ||||
|                 1, | ||||
|                 MaxBioLength, | ||||
|                 bio.Length | ||||
|             ), | ||||
|             _ => null, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     public static ValidationError? ValidateAvatar(string? avatar) | ||||
|     { | ||||
|         return avatar?.Length switch | ||||
|         { | ||||
|             0 => ValidationError.GenericValidationError("Avatar cannot be empty", null), | ||||
|             > MaxAvatarLength => ValidationError.GenericValidationError( | ||||
|                 "Avatar is too large", | ||||
|                 null | ||||
|             ), | ||||
|             _ => null, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
 | ||||
|     private static partial Regex UsernameRegex(); | ||||
| 
 | ||||
|     [GeneratedRegex( | ||||
|         """^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", | ||||
|         RegexOptions.IgnoreCase, | ||||
|         "en-NL" | ||||
|     )] | ||||
|     private static partial Regex MemberRegex(); | ||||
| } | ||||
|  | @ -1,7 +1,3 @@ | |||
| using System.Text.RegularExpressions; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Utils; | ||||
| 
 | ||||
| /// <summary> | ||||
|  | @ -9,76 +5,6 @@ namespace Foxnouns.Backend.Utils; | |||
| /// </summary> | ||||
| public static partial class ValidationUtils | ||||
| { | ||||
|     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 (!MemberRegex().IsMatch(memberName)) | ||||
|             return memberName.Length switch | ||||
|             { | ||||
|                 < 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length), | ||||
|                 > 100 => 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(); | ||||
|  | @ -95,375 +21,4 @@ public static partial class ValidationUtils | |||
| 
 | ||||
|         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, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     private const int MaxLinks = 25; | ||||
|     private const int MaxLinkLength = 256; | ||||
| 
 | ||||
|     public static IEnumerable<(string, ValidationError?)> ValidateLinks(string[]? links) | ||||
|     { | ||||
|         if (links == null) | ||||
|             return []; | ||||
|         if (links.Length > MaxLinks) | ||||
|             return | ||||
|             [ | ||||
|                 ("links", ValidationError.LengthError("Too many links", 0, MaxLinks, links.Length)), | ||||
|             ]; | ||||
| 
 | ||||
|         var errors = new List<(string, ValidationError?)>(); | ||||
|         foreach (var (link, idx) in links.Select((l, i) => (l, i))) | ||||
|         { | ||||
|             switch (link.Length) | ||||
|             { | ||||
|                 case 0: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"links.{idx}", | ||||
|                             ValidationError.LengthError("Link cannot be empty", 1, 256, 0) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|                 case > MaxLinkLength: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"links.{idx}", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Link is too long", | ||||
|                                 1, | ||||
|                                 MaxLinkLength, | ||||
|                                 link.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return errors; | ||||
|     } | ||||
| 
 | ||||
|     public const int MaxBioLength = 1024; | ||||
|     public const int MaxAvatarLength = 1_500_000; | ||||
| 
 | ||||
|     public static ValidationError? ValidateBio(string? bio) | ||||
|     { | ||||
|         return bio?.Length switch | ||||
|         { | ||||
|             0 => ValidationError.LengthError("Bio is too short", 1, MaxBioLength, bio.Length), | ||||
|             > MaxBioLength => ValidationError.LengthError( | ||||
|                 "Bio is too long", | ||||
|                 1, | ||||
|                 MaxBioLength, | ||||
|                 bio.Length | ||||
|             ), | ||||
|             _ => null, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     public static ValidationError? ValidateAvatar(string? avatar) | ||||
|     { | ||||
|         return avatar?.Length switch | ||||
|         { | ||||
|             0 => ValidationError.GenericValidationError("Avatar cannot be empty", null), | ||||
|             > MaxAvatarLength => ValidationError.GenericValidationError( | ||||
|                 "Avatar is too large", | ||||
|                 null | ||||
|             ), | ||||
|             _ => null, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     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, | ||||
|                         Limits.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 > Limits.FieldNameLimit: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"fields.{index}.name", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Field name is too long", | ||||
|                                 1, | ||||
|                                 Limits.FieldNameLimit, | ||||
|                                 field.Name.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|                 case < 1: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"fields.{index}.name", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Field name is too short", | ||||
|                                 1, | ||||
|                                 Limits.FieldNameLimit, | ||||
|                                 field.Name.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|             } | ||||
| 
 | ||||
|             errors = errors | ||||
|                 .Concat( | ||||
|                     ValidateFieldEntries( | ||||
|                         field.Entries, | ||||
|                         customPreferences, | ||||
|                         $"fields.{index}.entries" | ||||
|                     ) | ||||
|                 ) | ||||
|                 .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 > Limits.FieldEntriesLimit) | ||||
|             errors.Add( | ||||
|                 ( | ||||
|                     errorPrefix, | ||||
|                     ValidationError.LengthError( | ||||
|                         "Field has too many entries", | ||||
|                         0, | ||||
|                         Limits.FieldEntriesLimit, | ||||
|                         entries.Length | ||||
|                     ) | ||||
|                 ) | ||||
|             ); | ||||
| 
 | ||||
|         // Same as above, no overwhelming this function with a ridiculous amount of entries | ||||
|         if (entries.Length > Limits.FieldEntriesLimit + 50) | ||||
|             return errors; | ||||
| 
 | ||||
|         foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) | ||||
|         { | ||||
|             switch (entry.Value.Length) | ||||
|             { | ||||
|                 case > Limits.FieldEntryTextLimit: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"{errorPrefix}.{entryIdx}.value", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Field value is too long", | ||||
|                                 1, | ||||
|                                 Limits.FieldEntryTextLimit, | ||||
|                                 entry.Value.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|                 case < 1: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"{errorPrefix}.{entryIdx}.value", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Field value is too short", | ||||
|                                 1, | ||||
|                                 Limits.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}.{entryIdx}.status", | ||||
|                         ValidationError.GenericValidationError("Invalid status", entry.Status) | ||||
|                     ) | ||||
|                 ); | ||||
|         } | ||||
| 
 | ||||
|         return errors; | ||||
|     } | ||||
| 
 | ||||
|     public static IEnumerable<(string, ValidationError?)> ValidatePronouns( | ||||
|         Pronoun[]? entries, | ||||
|         IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences, | ||||
|         string errorPrefix = "pronouns" | ||||
|     ) | ||||
|     { | ||||
|         if (entries == null || entries.Length == 0) | ||||
|             return []; | ||||
|         var errors = new List<(string, ValidationError?)>(); | ||||
| 
 | ||||
|         if (entries.Length > Limits.FieldEntriesLimit) | ||||
|             errors.Add( | ||||
|                 ( | ||||
|                     errorPrefix, | ||||
|                     ValidationError.LengthError( | ||||
|                         "Too many pronouns", | ||||
|                         0, | ||||
|                         Limits.FieldEntriesLimit, | ||||
|                         entries.Length | ||||
|                     ) | ||||
|                 ) | ||||
|             ); | ||||
| 
 | ||||
|         // Same as above, no overwhelming this function with a ridiculous amount of entries | ||||
|         if (entries.Length > Limits.FieldEntriesLimit + 50) | ||||
|             return errors; | ||||
| 
 | ||||
|         foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) | ||||
|         { | ||||
|             switch (entry.Value.Length) | ||||
|             { | ||||
|                 case > Limits.FieldEntryTextLimit: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"{errorPrefix}.{entryIdx}.value", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Pronoun value is too long", | ||||
|                                 1, | ||||
|                                 Limits.FieldEntryTextLimit, | ||||
|                                 entry.Value.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|                 case < 1: | ||||
|                     errors.Add( | ||||
|                         ( | ||||
|                             $"{errorPrefix}.{entryIdx}.value", | ||||
|                             ValidationError.LengthError( | ||||
|                                 "Pronoun value is too short", | ||||
|                                 1, | ||||
|                                 Limits.FieldEntryTextLimit, | ||||
|                                 entry.Value.Length | ||||
|                             ) | ||||
|                         ) | ||||
|                     ); | ||||
|                     break; | ||||
|             } | ||||
| 
 | ||||
|             if (entry.DisplayText != null) | ||||
|             { | ||||
|                 switch (entry.DisplayText.Length) | ||||
|                 { | ||||
|                     case > Limits.FieldEntryTextLimit: | ||||
|                         errors.Add( | ||||
|                             ( | ||||
|                                 $"{errorPrefix}.{entryIdx}.display_text", | ||||
|                                 ValidationError.LengthError( | ||||
|                                     "Pronoun display text is too long", | ||||
|                                     1, | ||||
|                                     Limits.FieldEntryTextLimit, | ||||
|                                     entry.Value.Length | ||||
|                                 ) | ||||
|                             ) | ||||
|                         ); | ||||
|                         break; | ||||
|                     case < 1: | ||||
|                         errors.Add( | ||||
|                             ( | ||||
|                                 $"{errorPrefix}.{entryIdx}.display_text", | ||||
|                                 ValidationError.LengthError( | ||||
|                                     "Pronoun display text is too short", | ||||
|                                     1, | ||||
|                                     Limits.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}.{entryIdx}.status", | ||||
|                         ValidationError.GenericValidationError("Invalid status", entry.Status) | ||||
|                     ) | ||||
|                 ); | ||||
|         } | ||||
| 
 | ||||
|         return errors; | ||||
|     } | ||||
| 
 | ||||
|     [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
 | ||||
|     private static partial Regex UsernameRegex(); | ||||
| 
 | ||||
|     [GeneratedRegex( | ||||
|         """^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", | ||||
|         RegexOptions.IgnoreCase, | ||||
|         "en-NL" | ||||
|     )] | ||||
|     private static partial Regex MemberRegex(); | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue