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…
Reference in a new issue