feat: make some limits configurable
This commit is contained in:
		
							parent
							
								
									74800b46ef
								
							
						
					
					
						commit
						373d97e70a
					
				
					 11 changed files with 312 additions and 218 deletions
				
			
		|  | @ -31,6 +31,7 @@ public class Config | ||||||
|     public LoggingConfig Logging { get; init; } = new(); |     public LoggingConfig Logging { get; init; } = new(); | ||||||
|     public DatabaseConfig Database { get; init; } = new(); |     public DatabaseConfig Database { get; init; } = new(); | ||||||
|     public StorageConfig Storage { get; init; } = new(); |     public StorageConfig Storage { get; init; } = new(); | ||||||
|  |     public LimitsConfig Limits { get; init; } = new(); | ||||||
|     public EmailAuthConfig EmailAuth { get; init; } = new(); |     public EmailAuthConfig EmailAuth { get; init; } = new(); | ||||||
|     public DiscordAuthConfig DiscordAuth { get; init; } = new(); |     public DiscordAuthConfig DiscordAuth { get; init; } = new(); | ||||||
|     public GoogleAuthConfig GoogleAuth { get; init; } = new(); |     public GoogleAuthConfig GoogleAuth { get; init; } = new(); | ||||||
|  | @ -93,4 +94,17 @@ public class Config | ||||||
|         public string? ClientId { get; init; } |         public string? ClientId { get; init; } | ||||||
|         public string? ClientSecret { get; init; } |         public string? ClientSecret { get; init; } | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     public class LimitsConfig | ||||||
|  |     { | ||||||
|  |         public int MaxMemberCount { get; init; } = 1000; | ||||||
|  | 
 | ||||||
|  |         public int MaxUsernameLength { get; init; } = 40; | ||||||
|  |         public int MaxMemberNameLength { get; init; } = 100; | ||||||
|  |         public int MaxDisplayNameLength { get; init; } = 100; | ||||||
|  |         public int MaxLinks { get; init; } = 25; | ||||||
|  |         public int MaxLinkLength { get; init; } = 256; | ||||||
|  |         public int MaxBioLength { get; init; } = 1024; | ||||||
|  |         public int MaxAvatarLength { get; init; } = 1_500_000; | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -38,7 +38,9 @@ public class MembersController( | ||||||
|     ISnowflakeGenerator snowflakeGenerator, |     ISnowflakeGenerator snowflakeGenerator, | ||||||
|     ObjectStorageService objectStorageService, |     ObjectStorageService objectStorageService, | ||||||
|     IQueue queue, |     IQueue queue, | ||||||
|     IClock clock |     IClock clock, | ||||||
|  |     ValidationService validationService, | ||||||
|  |     Config config | ||||||
| ) : ApiControllerBase | ) : ApiControllerBase | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<MembersController>(); |     private readonly ILogger _logger = logger.ForContext<MembersController>(); | ||||||
|  | @ -65,8 +67,6 @@ public class MembersController( | ||||||
|         return Ok(memberRenderer.RenderMember(member, CurrentToken)); |         return Ok(memberRenderer.RenderMember(member, CurrentToken)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public const int MaxMemberCount = 1000; |  | ||||||
| 
 |  | ||||||
|     [HttpPost("/api/v2/users/@me/members")] |     [HttpPost("/api/v2/users/@me/members")] | ||||||
|     [ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)] |     [ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)] | ||||||
|     [Authorize("member.create")] |     [Authorize("member.create")] | ||||||
|  | @ -77,10 +77,10 @@ public class MembersController( | ||||||
|     { |     { | ||||||
|         ValidationUtils.Validate( |         ValidationUtils.Validate( | ||||||
|             [ |             [ | ||||||
|                 ("name", ValidationUtils.ValidateMemberName(req.Name)), |                 ("name", validationService.ValidateMemberName(req.Name)), | ||||||
|                 ("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)), |                 ("display_name", validationService.ValidateDisplayName(req.DisplayName)), | ||||||
|                 ("bio", ValidationUtils.ValidateBio(req.Bio)), |                 ("bio", validationService.ValidateBio(req.Bio)), | ||||||
|                 ("avatar", ValidationUtils.ValidateAvatar(req.Avatar)), |                 ("avatar", validationService.ValidateAvatar(req.Avatar)), | ||||||
|                 .. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences), |                 .. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences), | ||||||
|                 .. ValidationUtils.ValidateFieldEntries( |                 .. ValidationUtils.ValidateFieldEntries( | ||||||
|                     req.Names?.ToArray(), |                     req.Names?.ToArray(), | ||||||
|  | @ -91,12 +91,12 @@ public class MembersController( | ||||||
|                     req.Pronouns?.ToArray(), |                     req.Pronouns?.ToArray(), | ||||||
|                     CurrentUser!.CustomPreferences |                     CurrentUser!.CustomPreferences | ||||||
|                 ), |                 ), | ||||||
|                 .. ValidationUtils.ValidateLinks(req.Links), |                 .. validationService.ValidateLinks(req.Links), | ||||||
|             ] |             ] | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         int memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct); |         int memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct); | ||||||
|         if (memberCount >= MaxMemberCount) |         if (memberCount >= config.Limits.MaxMemberCount) | ||||||
|             throw new ApiError.BadRequest("Maximum number of members reached"); |             throw new ApiError.BadRequest("Maximum number of members reached"); | ||||||
| 
 | 
 | ||||||
|         var member = new Member |         var member = new Member | ||||||
|  | @ -163,25 +163,25 @@ public class MembersController( | ||||||
|         // These should only take effect when a member's name is changed, not on other changes. |         // These should only take effect when a member's name is changed, not on other changes. | ||||||
|         if (req.Name != null && req.Name != member.Name) |         if (req.Name != null && req.Name != member.Name) | ||||||
|         { |         { | ||||||
|             errors.Add(("name", ValidationUtils.ValidateMemberName(req.Name))); |             errors.Add(("name", validationService.ValidateMemberName(req.Name))); | ||||||
|             member.Name = req.Name; |             member.Name = req.Name; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.HasProperty(nameof(req.DisplayName))) |         if (req.HasProperty(nameof(req.DisplayName))) | ||||||
|         { |         { | ||||||
|             errors.Add(("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName))); |             errors.Add(("display_name", validationService.ValidateDisplayName(req.DisplayName))); | ||||||
|             member.DisplayName = req.DisplayName; |             member.DisplayName = req.DisplayName; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.HasProperty(nameof(req.Bio))) |         if (req.HasProperty(nameof(req.Bio))) | ||||||
|         { |         { | ||||||
|             errors.Add(("bio", ValidationUtils.ValidateBio(req.Bio))); |             errors.Add(("bio", validationService.ValidateBio(req.Bio))); | ||||||
|             member.Bio = req.Bio; |             member.Bio = req.Bio; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.HasProperty(nameof(req.Links))) |         if (req.HasProperty(nameof(req.Links))) | ||||||
|         { |         { | ||||||
|             errors.AddRange(ValidationUtils.ValidateLinks(req.Links)); |             errors.AddRange(validationService.ValidateLinks(req.Links)); | ||||||
|             member.Links = req.Links ?? []; |             member.Links = req.Links ?? []; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | @ -228,7 +228,7 @@ public class MembersController( | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.HasProperty(nameof(req.Avatar))) |         if (req.HasProperty(nameof(req.Avatar))) | ||||||
|             errors.Add(("avatar", ValidationUtils.ValidateAvatar(req.Avatar))); |             errors.Add(("avatar", validationService.ValidateAvatar(req.Avatar))); | ||||||
| 
 | 
 | ||||||
|         ValidationUtils.Validate(errors); |         ValidationUtils.Validate(errors); | ||||||
|         // This is fired off regardless of whether the transaction is committed |         // This is fired off regardless of whether the transaction is committed | ||||||
|  |  | ||||||
|  | @ -20,7 +20,7 @@ using Microsoft.AspNetCore.Mvc; | ||||||
| namespace Foxnouns.Backend.Controllers; | namespace Foxnouns.Backend.Controllers; | ||||||
| 
 | 
 | ||||||
| [Route("/api/v2/meta")] | [Route("/api/v2/meta")] | ||||||
| public partial class MetaController : ApiControllerBase | public partial class MetaController(Config config) : ApiControllerBase | ||||||
| { | { | ||||||
|     private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc"; |     private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc"; | ||||||
| 
 | 
 | ||||||
|  | @ -40,8 +40,8 @@ public partial class MetaController : ApiControllerBase | ||||||
|                     (int)FoxnounsMetrics.UsersActiveDayCount.Value |                     (int)FoxnounsMetrics.UsersActiveDayCount.Value | ||||||
|                 ), |                 ), | ||||||
|                 new LimitsResponse( |                 new LimitsResponse( | ||||||
|                     MembersController.MaxMemberCount, |                     config.Limits.MaxMemberCount, | ||||||
|                     ValidationUtils.MaxBioLength, |                     config.Limits.MaxBioLength, | ||||||
|                     ValidationUtils.MaxCustomPreferences, |                     ValidationUtils.MaxCustomPreferences, | ||||||
|                     AuthUtils.MaxAuthMethodsPerType, |                     AuthUtils.MaxAuthMethodsPerType, | ||||||
|                     FlagsController.MaxFlagCount |                     FlagsController.MaxFlagCount | ||||||
|  |  | ||||||
|  | @ -35,7 +35,8 @@ public class UsersController( | ||||||
|     UserRendererService userRenderer, |     UserRendererService userRenderer, | ||||||
|     ISnowflakeGenerator snowflakeGenerator, |     ISnowflakeGenerator snowflakeGenerator, | ||||||
|     IQueue queue, |     IQueue queue, | ||||||
|     IClock clock |     IClock clock, | ||||||
|  |     ValidationService validationService | ||||||
| ) : ApiControllerBase | ) : ApiControllerBase | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<UsersController>(); |     private readonly ILogger _logger = logger.ForContext<UsersController>(); | ||||||
|  | @ -65,25 +66,25 @@ public class UsersController( | ||||||
| 
 | 
 | ||||||
|         if (req.Username != null && req.Username != user.Username) |         if (req.Username != null && req.Username != user.Username) | ||||||
|         { |         { | ||||||
|             errors.Add(("username", ValidationUtils.ValidateUsername(req.Username))); |             errors.Add(("username", validationService.ValidateUsername(req.Username))); | ||||||
|             user.Username = req.Username; |             user.Username = req.Username; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.HasProperty(nameof(req.DisplayName))) |         if (req.HasProperty(nameof(req.DisplayName))) | ||||||
|         { |         { | ||||||
|             errors.Add(("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName))); |             errors.Add(("display_name", validationService.ValidateDisplayName(req.DisplayName))); | ||||||
|             user.DisplayName = req.DisplayName; |             user.DisplayName = req.DisplayName; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.HasProperty(nameof(req.Bio))) |         if (req.HasProperty(nameof(req.Bio))) | ||||||
|         { |         { | ||||||
|             errors.Add(("bio", ValidationUtils.ValidateBio(req.Bio))); |             errors.Add(("bio", validationService.ValidateBio(req.Bio))); | ||||||
|             user.Bio = req.Bio; |             user.Bio = req.Bio; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.HasProperty(nameof(req.Links))) |         if (req.HasProperty(nameof(req.Links))) | ||||||
|         { |         { | ||||||
|             errors.AddRange(ValidationUtils.ValidateLinks(req.Links)); |             errors.AddRange(validationService.ValidateLinks(req.Links)); | ||||||
|             user.Links = req.Links ?? []; |             user.Links = req.Links ?? []; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | @ -123,7 +124,7 @@ public class UsersController( | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.HasProperty(nameof(req.Avatar))) |         if (req.HasProperty(nameof(req.Avatar))) | ||||||
|             errors.Add(("avatar", ValidationUtils.ValidateAvatar(req.Avatar))); |             errors.Add(("avatar", validationService.ValidateAvatar(req.Avatar))); | ||||||
| 
 | 
 | ||||||
|         if (req.HasProperty(nameof(req.MemberTitle))) |         if (req.HasProperty(nameof(req.MemberTitle))) | ||||||
|         { |         { | ||||||
|  | @ -133,7 +134,9 @@ public class UsersController( | ||||||
|             } |             } | ||||||
|             else |             else | ||||||
|             { |             { | ||||||
|                 errors.Add(("member_title", ValidationUtils.ValidateDisplayName(req.MemberTitle))); |                 errors.Add( | ||||||
|  |                     ("member_title", validationService.ValidateDisplayName(req.MemberTitle)) | ||||||
|  |                 ); | ||||||
|                 user.MemberTitle = req.MemberTitle; |                 user.MemberTitle = req.MemberTitle; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -122,6 +122,7 @@ public static class WebApplicationExtensions | ||||||
|                     .AddScoped<FediverseAuthService>() |                     .AddScoped<FediverseAuthService>() | ||||||
|                     .AddScoped<ObjectStorageService>() |                     .AddScoped<ObjectStorageService>() | ||||||
|                     .AddTransient<DataCleanupService>() |                     .AddTransient<DataCleanupService>() | ||||||
|  |                     .AddTransient<ValidationService>() | ||||||
|                     // Background services |                     // Background services | ||||||
|                     .AddHostedService<PeriodicTasksService>() |                     .AddHostedService<PeriodicTasksService>() | ||||||
|                     // Transient jobs |                     // Transient jobs | ||||||
|  |  | ||||||
|  | @ -29,7 +29,8 @@ public class AuthService( | ||||||
|     ILogger logger, |     ILogger logger, | ||||||
|     DatabaseContext db, |     DatabaseContext db, | ||||||
|     ISnowflakeGenerator snowflakeGenerator, |     ISnowflakeGenerator snowflakeGenerator, | ||||||
|     UserRendererService userRenderer |     UserRendererService userRenderer, | ||||||
|  |     ValidationService validationService | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<AuthService>(); |     private readonly ILogger _logger = logger.ForContext<AuthService>(); | ||||||
|  | @ -49,7 +50,7 @@ public class AuthService( | ||||||
|         // Validate username and whether it's not taken |         // Validate username and whether it's not taken | ||||||
|         ValidationUtils.Validate( |         ValidationUtils.Validate( | ||||||
|             [ |             [ | ||||||
|                 ("username", ValidationUtils.ValidateUsername(username)), |                 ("username", validationService.ValidateUsername(username)), | ||||||
|                 ("password", ValidationUtils.ValidatePassword(password)), |                 ("password", ValidationUtils.ValidatePassword(password)), | ||||||
|             ] |             ] | ||||||
|         ); |         ); | ||||||
|  | @ -97,7 +98,7 @@ public class AuthService( | ||||||
|         AssertValidAuthType(authType, instance); |         AssertValidAuthType(authType, instance); | ||||||
| 
 | 
 | ||||||
|         // Validate username and whether it's not taken |         // Validate username and whether it's not taken | ||||||
|         ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(username))]); |         ValidationUtils.Validate([("username", validationService.ValidateUsername(username))]); | ||||||
|         if (await db.Users.AnyAsync(u => u.Username == username, ct)) |         if (await db.Users.AnyAsync(u => u.Username == username, ct)) | ||||||
|             throw new ApiError.BadRequest("Username is already taken", "username", username); |             throw new ApiError.BadRequest("Username is already taken", "username", username); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										256
									
								
								Foxnouns.Backend/Services/ValidationService.Strings.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								Foxnouns.Backend/Services/ValidationService.Strings.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,256 @@ | ||||||
|  | // 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.Services; | ||||||
|  | 
 | ||||||
|  | public partial class ValidationService | ||||||
|  | { | ||||||
|  |     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 ValidationError? ValidateUsername(string username) | ||||||
|  |     { | ||||||
|  |         if (!UsernameRegex().IsMatch(username)) | ||||||
|  |         { | ||||||
|  |             if (username.Length < 2) | ||||||
|  |             { | ||||||
|  |                 return ValidationError.LengthError( | ||||||
|  |                     "Username is too short", | ||||||
|  |                     2, | ||||||
|  |                     _limits.MaxUsernameLength, | ||||||
|  |                     username.Length | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (username.Length > _limits.MaxUsernameLength) | ||||||
|  |             { | ||||||
|  |                 return ValidationError.LengthError( | ||||||
|  |                     "Username is too long", | ||||||
|  |                     2, | ||||||
|  |                     _limits.MaxUsernameLength, | ||||||
|  |                     username.Length | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             return 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 ValidationError? ValidateMemberName(string memberName) | ||||||
|  |     { | ||||||
|  |         if (!MemberRegex().IsMatch(memberName)) | ||||||
|  |         { | ||||||
|  |             if (memberName.Length < 1) | ||||||
|  |             { | ||||||
|  |                 return ValidationError.LengthError( | ||||||
|  |                     "Name is too short", | ||||||
|  |                     1, | ||||||
|  |                     _limits.MaxMemberNameLength, | ||||||
|  |                     memberName.Length | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (memberName.Length > _limits.MaxMemberNameLength) | ||||||
|  |             { | ||||||
|  |                 return ValidationError.LengthError( | ||||||
|  |                     "Name is too long", | ||||||
|  |                     1, | ||||||
|  |                     _limits.MaxMemberNameLength, | ||||||
|  |                     memberName.Length | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             return 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 ValidationError? ValidateDisplayName(string? displayName) | ||||||
|  |     { | ||||||
|  |         if (displayName?.Length == 0) | ||||||
|  |         { | ||||||
|  |             return ValidationError.LengthError( | ||||||
|  |                 "Display name is too short", | ||||||
|  |                 1, | ||||||
|  |                 _limits.MaxDisplayNameLength, | ||||||
|  |                 displayName.Length | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (displayName?.Length > _limits.MaxDisplayNameLength) | ||||||
|  |         { | ||||||
|  |             return ValidationError.LengthError( | ||||||
|  |                 "Display name is too long", | ||||||
|  |                 1, | ||||||
|  |                 _limits.MaxDisplayNameLength, | ||||||
|  |                 displayName.Length | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public IEnumerable<(string, ValidationError?)> ValidateLinks(string[]? links) | ||||||
|  |     { | ||||||
|  |         if (links == null) | ||||||
|  |             return []; | ||||||
|  |         if (links.Length > _limits.MaxLinks) | ||||||
|  |         { | ||||||
|  |             return | ||||||
|  |             [ | ||||||
|  |                 ( | ||||||
|  |                     "links", | ||||||
|  |                     ValidationError.LengthError("Too many links", 0, _limits.MaxLinks, links.Length) | ||||||
|  |                 ), | ||||||
|  |             ]; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         var errors = new List<(string, ValidationError?)>(); | ||||||
|  |         foreach ((string link, int idx) in links.Select((l, i) => (l, i))) | ||||||
|  |         { | ||||||
|  |             if (link.Length == 0) | ||||||
|  |             { | ||||||
|  |                 errors.Add( | ||||||
|  |                     ( | ||||||
|  |                         $"links.{idx}", | ||||||
|  |                         ValidationError.LengthError( | ||||||
|  |                             "Link cannot be empty", | ||||||
|  |                             1, | ||||||
|  |                             _limits.MaxLinkLength, | ||||||
|  |                             0 | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |             else if (link.Length > _limits.MaxLinkLength) | ||||||
|  |             { | ||||||
|  |                 errors.Add( | ||||||
|  |                     ( | ||||||
|  |                         $"links.{idx}", | ||||||
|  |                         ValidationError.LengthError( | ||||||
|  |                             "Link is too long", | ||||||
|  |                             1, | ||||||
|  |                             _limits.MaxLinkLength, | ||||||
|  |                             link.Length | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return errors; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public ValidationError? ValidateBio(string? bio) | ||||||
|  |     { | ||||||
|  |         if (bio?.Length == 0) | ||||||
|  |         { | ||||||
|  |             return ValidationError.LengthError( | ||||||
|  |                 "Bio is too short", | ||||||
|  |                 1, | ||||||
|  |                 _limits.MaxBioLength, | ||||||
|  |                 bio.Length | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (bio?.Length > _limits.MaxBioLength) | ||||||
|  |         { | ||||||
|  |             return ValidationError.LengthError( | ||||||
|  |                 "Bio is too long", | ||||||
|  |                 1, | ||||||
|  |                 _limits.MaxBioLength, | ||||||
|  |                 bio.Length | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public ValidationError? ValidateAvatar(string? avatar) | ||||||
|  |     { | ||||||
|  |         if (avatar?.Length == 0) | ||||||
|  |         { | ||||||
|  |             return ValidationError.GenericValidationError("Avatar cannot be empty", null); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (avatar?.Length > _limits.MaxAvatarLength) | ||||||
|  |         { | ||||||
|  |             return ValidationError.GenericValidationError("Avatar is too large", null); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-US")]
 | ||||||
|  |     private static partial Regex UsernameRegex(); | ||||||
|  | 
 | ||||||
|  |     [GeneratedRegex( | ||||||
|  |         """^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", | ||||||
|  |         RegexOptions.IgnoreCase, | ||||||
|  |         "en-US" | ||||||
|  |     )] | ||||||
|  |     private static partial Regex MemberRegex(); | ||||||
|  | } | ||||||
							
								
								
									
										6
									
								
								Foxnouns.Backend/Services/ValidationService.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								Foxnouns.Backend/Services/ValidationService.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | ||||||
|  | namespace Foxnouns.Backend.Services; | ||||||
|  | 
 | ||||||
|  | public partial class ValidationService(Config config) | ||||||
|  | { | ||||||
|  |     private readonly Config.LimitsConfig _limits = config.Limits; | ||||||
|  | } | ||||||
|  | @ -135,7 +135,7 @@ public static class AuthUtils | ||||||
|         Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('='); |         Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('='); | ||||||
| 
 | 
 | ||||||
|     public static string RandomToken(int bytes = 48) => |     public static string RandomToken(int bytes = 48) => | ||||||
|         RandomUrlUnsafeToken() |         RandomUrlUnsafeToken(bytes) | ||||||
|             // Make the token URL-safe |             // Make the token URL-safe | ||||||
|             .Replace('+', '-') |             .Replace('+', '-') | ||||||
|             .Replace('/', '_'); |             .Replace('/', '_'); | ||||||
|  |  | ||||||
|  | @ -12,190 +12,10 @@ | ||||||
| // | // | ||||||
| // You should have received a copy of the GNU Affero General Public License | // 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/>. | // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
| using System.Text.RegularExpressions; |  | ||||||
| 
 |  | ||||||
| namespace Foxnouns.Backend.Utils; | namespace Foxnouns.Backend.Utils; | ||||||
| 
 | 
 | ||||||
| public static partial class ValidationUtils | 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 ((string link, int 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, |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public const int MaximumReportContextLength = 512; |     public const int MaximumReportContextLength = 512; | ||||||
| 
 | 
 | ||||||
|     public static ValidationError? ValidateReportContext(string? context) => |     public static ValidationError? ValidateReportContext(string? context) => | ||||||
|  | @ -223,14 +43,4 @@ public static partial class ValidationUtils | ||||||
|             ), |             ), | ||||||
|             _ => 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(); |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -43,6 +43,9 @@ AccessKey = <s3AccessKey> | ||||||
| SecretKey = <s3SecretKey> | SecretKey = <s3SecretKey> | ||||||
| Bucket = pronounscc | Bucket = pronounscc | ||||||
| 
 | 
 | ||||||
|  | [Limits] | ||||||
|  | MaxMemberCount = 5000 | ||||||
|  | 
 | ||||||
| [EmailAuth] | [EmailAuth] | ||||||
| ; The address that emails will be sent from. If not set, email auth is disabled. | ; The address that emails will be sent from. If not set, email auth is disabled. | ||||||
| From = noreply@accounts.pronouns.cc | From = noreply@accounts.pronouns.cc | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue