Compare commits
4 commits
59496a8cd8
...
8b1d5b2c1b
Author | SHA1 | Date | |
---|---|---|---|
8b1d5b2c1b | |||
71b59dbb00 | |||
f435ad4cf5 | |||
7c52ab759c |
20 changed files with 2996 additions and 644 deletions
|
@ -3,3 +3,6 @@
|
||||||
resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none
|
resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none
|
||||||
# This is raised for every single property of records returned by endpoints
|
# This is raised for every single property of records returned by endpoints
|
||||||
resharper_not_accessed_positional_property_local_highlighting = none
|
resharper_not_accessed_positional_property_local_highlighting = none
|
||||||
|
|
||||||
|
[*generated.cs]
|
||||||
|
generated_code = true
|
||||||
|
|
|
@ -27,7 +27,7 @@ public class MetaController : ApiControllerBase
|
||||||
new Limits(
|
new Limits(
|
||||||
MemberCount: MembersController.MaxMemberCount,
|
MemberCount: MembersController.MaxMemberCount,
|
||||||
BioLength: ValidationUtils.MaxBioLength,
|
BioLength: ValidationUtils.MaxBioLength,
|
||||||
CustomPreferences: UsersController.MaxCustomPreferences
|
CustomPreferences: ValidationUtils.MaxCustomPreferences
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
@ -197,11 +197,11 @@ public class UsersController(
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> UpdateCustomPreferencesAsync(
|
public async Task<IActionResult> UpdateCustomPreferencesAsync(
|
||||||
[FromBody] List<CustomPreferencesUpdateRequest> req,
|
[FromBody] List<CustomPreferenceUpdate> req,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
ValidationUtils.Validate(ValidateCustomPreferences(req));
|
ValidationUtils.Validate(ValidationUtils.ValidateCustomPreferences(req));
|
||||||
|
|
||||||
var user = await db.ResolveUserAsync(CurrentUser!.Id, ct);
|
var user = await db.ResolveUserAsync(CurrentUser!.Id, ct);
|
||||||
var preferences = user
|
var preferences = user
|
||||||
|
@ -241,7 +241,7 @@ public class UsersController(
|
||||||
}
|
}
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
|
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
|
||||||
public class CustomPreferencesUpdateRequest
|
public class CustomPreferenceUpdate
|
||||||
{
|
{
|
||||||
public Snowflake? Id { get; init; }
|
public Snowflake? Id { get; init; }
|
||||||
public required string Icon { get; set; }
|
public required string Icon { get; set; }
|
||||||
|
@ -251,34 +251,6 @@ public class UsersController(
|
||||||
public bool Favourite { get; set; }
|
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 class UpdateUserRequest : PatchRequest
|
||||||
{
|
{
|
||||||
public string? Username { get; init; }
|
public string? Username { get; init; }
|
||||||
|
|
2060
Foxnouns.Backend/Utils/BootstrapIcons.Icons.generated.cs
Normal file
2060
Foxnouns.Backend/Utils/BootstrapIcons.Icons.generated.cs
Normal file
File diff suppressed because it is too large
Load diff
6
Foxnouns.Backend/Utils/BootstrapIcons.cs
Normal file
6
Foxnouns.Backend/Utils/BootstrapIcons.cs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
namespace Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
|
public static partial class BootstrapIcons
|
||||||
|
{
|
||||||
|
public static bool IsValid(string icon) => Icons.Contains(icon);
|
||||||
|
}
|
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;
|
namespace Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -9,76 +5,6 @@ namespace Foxnouns.Backend.Utils;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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 void Validate(IEnumerable<(string, ValidationError?)> errors)
|
public static void Validate(IEnumerable<(string, ValidationError?)> errors)
|
||||||
{
|
{
|
||||||
errors = errors.Where(e => e.Item2 != null).ToList();
|
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);
|
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();
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,3 +2,4 @@
|
||||||
package-lock.json
|
package-lock.json
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
src/lib/icons.ts
|
||||||
|
|
42
Foxnouns.Frontend/icons.js
Normal file
42
Foxnouns.Frontend/icons.js
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
// This script regenerates the list of icons for the frontend (Foxnouns.Frontend/src/lib/icons.ts)
|
||||||
|
// and the backend (Foxnouns.Backend/Utils/BootstrapIcons.Icons.cs) from the currently installed version of Bootstrap Icons.
|
||||||
|
// Run with `pnpm node icons.js` in the frontend directory.
|
||||||
|
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
import icons from "bootstrap-icons/font/bootstrap-icons.json" with { type: "json" };
|
||||||
|
|
||||||
|
const keys = Object.keys(icons);
|
||||||
|
|
||||||
|
console.log(`Found ${keys.length} icons`);
|
||||||
|
const output = JSON.stringify(keys);
|
||||||
|
console.log(`Saving file as src/icons.ts`);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
"src/lib/icons.ts",
|
||||||
|
`// Generated code: DO NOT EDIT\n\nconst icons = ${output};\nexport default icons;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const csCode1 = `// <auto-generated />
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
|
public static partial class BootstrapIcons
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> Icons =
|
||||||
|
[
|
||||||
|
`;
|
||||||
|
|
||||||
|
const csCode2 = ` ];
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let csOutput = csCode1;
|
||||||
|
|
||||||
|
keys.forEach((element) => {
|
||||||
|
csOutput += ` "${element}",\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
csOutput += csCode2;
|
||||||
|
|
||||||
|
console.log("Writing C# code");
|
||||||
|
writeFileSync("../Foxnouns.Backend/Utils/BootstrapIcons.Icons.generated.cs", csOutput);
|
|
@ -2,14 +2,25 @@
|
||||||
import type { CustomPreference, FieldEntry } from "$api/models";
|
import type { CustomPreference, FieldEntry } from "$api/models";
|
||||||
import IconButton from "$components/IconButton.svelte";
|
import IconButton from "$components/IconButton.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
|
import { InputGroup, InputGroupText } from "@sveltestrap/sveltestrap";
|
||||||
import FieldEntryEditor from "./FieldEntryEditor.svelte";
|
import FieldEntryEditor from "./FieldEntryEditor.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
name: string;
|
name: string;
|
||||||
entries: FieldEntry[];
|
entries: FieldEntry[];
|
||||||
allPreferences: Record<string, CustomPreference>;
|
allPreferences: Record<string, CustomPreference>;
|
||||||
|
index?: number;
|
||||||
|
move?: (index: number, up: boolean) => void;
|
||||||
|
remove?: (index: number) => void;
|
||||||
};
|
};
|
||||||
let { name, entries = $bindable(), allPreferences }: Props = $props();
|
let {
|
||||||
|
name = $bindable(),
|
||||||
|
entries = $bindable(),
|
||||||
|
allPreferences,
|
||||||
|
index,
|
||||||
|
move,
|
||||||
|
remove,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let newEntry = $state("");
|
let newEntry = $state("");
|
||||||
|
|
||||||
|
@ -38,19 +49,45 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h4>{name}</h4>
|
{#if index !== undefined && move && remove}
|
||||||
|
<div class="d-flex">
|
||||||
{#each entries as _, index}
|
<InputGroup>
|
||||||
<FieldEntryEditor
|
<IconButton
|
||||||
{index}
|
icon="chevron-up"
|
||||||
bind:value={entries[index]}
|
color="secondary"
|
||||||
{allPreferences}
|
tooltip={$t("editor.move-field-up")}
|
||||||
{moveValue}
|
onclick={() => move(index, true)}
|
||||||
{removeValue}
|
|
||||||
/>
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-down"
|
||||||
|
color="secondary"
|
||||||
|
tooltip={$t("editor.move-field-down")}
|
||||||
|
onclick={() => move(index, false)}
|
||||||
|
/>
|
||||||
|
<InputGroupText>{$t("editor.field-name")}</InputGroupText>
|
||||||
|
<input class="form-control" bind:value={name} />
|
||||||
|
<IconButton
|
||||||
|
color="danger"
|
||||||
|
icon="trash3"
|
||||||
|
tooltip={$t("editor.remove-field")}
|
||||||
|
onclick={() => remove(index)}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<h4>{name}</h4>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each entries as _, i}
|
||||||
|
<FieldEntryEditor index={i} bind:value={entries[i]} {allPreferences} {moveValue} {removeValue} />
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<form class="input-group m-1" onsubmit={addEntry}>
|
<form class="input-group m-1" onsubmit={addEntry}>
|
||||||
<input type="text" class="form-control" bind:value={newEntry} />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
bind:value={newEntry}
|
||||||
|
placeholder={$t("editor.new-entry")}
|
||||||
|
/>
|
||||||
<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-entry")} />
|
<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-entry")} />
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
icon="chevron-down"
|
icon="chevron-down"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
tooltip={$t("editor.move-entry-down")}
|
tooltip={$t("editor.move-entry-down")}
|
||||||
onclick={() => moveValue(index, true)}
|
onclick={() => moveValue(index, false)}
|
||||||
/>
|
/>
|
||||||
<input type="text" class="form-control" bind:value={value.value} />
|
<input type="text" class="form-control" bind:value={value.value} />
|
||||||
<ButtonDropdown>
|
<ButtonDropdown>
|
||||||
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import type { CustomPreference, Field } from "$api/models";
|
||||||
|
import IconButton from "$components/IconButton.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import FieldEditor from "./FieldEditor.svelte";
|
||||||
|
import FormStatusMarker from "./FormStatusMarker.svelte";
|
||||||
|
import NoscriptWarning from "./NoscriptWarning.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
fields: Field[];
|
||||||
|
ok: { ok: boolean; error: RawApiError | null } | null;
|
||||||
|
allPreferences: Record<string, CustomPreference>;
|
||||||
|
update: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { fields = $bindable(), ok, allPreferences, update }: Props = $props();
|
||||||
|
|
||||||
|
let newFieldName = $state("");
|
||||||
|
|
||||||
|
const moveField = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == fields.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
const temp = fields[index];
|
||||||
|
fields[index] = fields[newIndex];
|
||||||
|
fields[newIndex] = temp;
|
||||||
|
fields = [...fields];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeField = (index: number) => {
|
||||||
|
fields.splice(index, 1);
|
||||||
|
fields = [...fields];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addField = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!newFieldName) return;
|
||||||
|
|
||||||
|
fields = [...fields, { name: newFieldName, entries: [] }];
|
||||||
|
newFieldName = "";
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NoscriptWarning />
|
||||||
|
<FormStatusMarker form={ok} />
|
||||||
|
|
||||||
|
<h4>{$t("edit-profile.editing-fields-header")}</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5>{$t("editor.add-field")}</h5>
|
||||||
|
<form class="input-group m-1" onsubmit={addField}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
bind:value={newFieldName}
|
||||||
|
placeholder={$t("editor.field-name")}
|
||||||
|
/>
|
||||||
|
<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-field")} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if fields.length > 0}
|
||||||
|
<hr />
|
||||||
|
{#each fields as field, index}
|
||||||
|
<FieldEditor
|
||||||
|
{index}
|
||||||
|
bind:name={field.name}
|
||||||
|
bind:entries={field.entries}
|
||||||
|
{allPreferences}
|
||||||
|
move={moveField}
|
||||||
|
remove={removeField}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" onclick={() => update()}>{$t("save-changes")}</button>
|
||||||
|
</div>
|
|
@ -133,7 +133,8 @@
|
||||||
"unlisted-label": "Hide from member list",
|
"unlisted-label": "Hide from member list",
|
||||||
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
|
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
|
||||||
"edit-names-pronouns-header": "Edit names and pronouns",
|
"edit-names-pronouns-header": "Edit names and pronouns",
|
||||||
"back-to-profile-tab": "Back to profile"
|
"back-to-profile-tab": "Back to profile",
|
||||||
|
"editing-fields-header": "Editing fields"
|
||||||
},
|
},
|
||||||
"save-changes": "Save changes",
|
"save-changes": "Save changes",
|
||||||
"change": "Change",
|
"change": "Change",
|
||||||
|
@ -145,6 +146,12 @@
|
||||||
"change-display-text": "Change display text",
|
"change-display-text": "Change display text",
|
||||||
"display-text-example": "Optional display text (e.g. it/its)",
|
"display-text-example": "Optional display text (e.g. it/its)",
|
||||||
"display-text-label": "Display text",
|
"display-text-label": "Display text",
|
||||||
"display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set."
|
"display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set.",
|
||||||
|
"move-field-up": "Move field up",
|
||||||
|
"move-field-down": "Move field down",
|
||||||
|
"remove-field": "Remove field",
|
||||||
|
"field-name": "Field name",
|
||||||
|
"add-field": "Add field",
|
||||||
|
"new-entry": "New entry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
4
Foxnouns.Frontend/src/lib/icons.ts
Normal file
4
Foxnouns.Frontend/src/lib/icons.ts
Normal file
File diff suppressed because one or more lines are too long
|
@ -5,6 +5,7 @@
|
||||||
import { mergePreferences } from "$api/models/user";
|
import { mergePreferences } from "$api/models/user";
|
||||||
import FieldEditor from "$components/editor/FieldEditor.svelte";
|
import FieldEditor from "$components/editor/FieldEditor.svelte";
|
||||||
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
import NoscriptWarning from "$components/editor/NoscriptWarning.svelte";
|
||||||
import PronounsEditor from "$components/editor/PronounsEditor.svelte";
|
import PronounsEditor from "$components/editor/PronounsEditor.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import log from "$lib/log";
|
import log from "$lib/log";
|
||||||
|
@ -37,16 +38,15 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<NoscriptWarning />
|
||||||
<FormStatusMarker form={ok} />
|
<FormStatusMarker form={ok} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} />
|
<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PronounsEditor bind:entries={pronouns} {allPreferences} />
|
<PronounsEditor bind:entries={pronouns} {allPreferences} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-primary" onclick={() => update()}>{$t("save-changes")}</button>
|
<button class="btn btn-primary" onclick={() => update()}>{$t("save-changes")}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import ApiError, { type RawApiError } from "$api/error";
|
||||||
|
import { mergePreferences, type User } from "$api/models/user";
|
||||||
|
import FieldsEditor from "$components/editor/FieldsEditor.svelte";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let fields = $state(data.user.fields);
|
||||||
|
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
let allPreferences = $derived(mergePreferences(data.user.custom_preferences));
|
||||||
|
|
||||||
|
const update = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiRequest<User>("PATCH", "/users/@me", {
|
||||||
|
body: { fields },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
fields = resp.fields;
|
||||||
|
ok = { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
ok = { ok: false, error: null };
|
||||||
|
log.error("Could not update fields:", e);
|
||||||
|
if (e instanceof ApiError) ok.error = e.obj;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FieldsEditor {fields} {ok} {allPreferences} {update} />
|
|
@ -4,6 +4,7 @@
|
||||||
import { mergePreferences, type User } from "$api/models/user";
|
import { mergePreferences, type User } from "$api/models/user";
|
||||||
import FieldEditor from "$components/editor/FieldEditor.svelte";
|
import FieldEditor from "$components/editor/FieldEditor.svelte";
|
||||||
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
import NoscriptWarning from "$components/editor/NoscriptWarning.svelte";
|
||||||
import PronounsEditor from "$components/editor/PronounsEditor.svelte";
|
import PronounsEditor from "$components/editor/PronounsEditor.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import log from "$lib/log";
|
import log from "$lib/log";
|
||||||
|
@ -14,9 +15,7 @@
|
||||||
|
|
||||||
let names = $state(data.user.names);
|
let names = $state(data.user.names);
|
||||||
let pronouns = $state(data.user.pronouns);
|
let pronouns = $state(data.user.pronouns);
|
||||||
|
|
||||||
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
|
||||||
let allPreferences = $derived(mergePreferences(data.user.custom_preferences));
|
let allPreferences = $derived(mergePreferences(data.user.custom_preferences));
|
||||||
|
|
||||||
const update = async () => {
|
const update = async () => {
|
||||||
|
@ -36,16 +35,15 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<NoscriptWarning />
|
||||||
<FormStatusMarker form={ok} />
|
<FormStatusMarker form={ok} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} />
|
<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PronounsEditor bind:entries={pronouns} {allPreferences} />
|
<PronounsEditor bind:entries={pronouns} {allPreferences} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-primary" onclick={() => update()}>{$t("save-changes")}</button>
|
<button class="btn btn-primary" onclick={() => update()}>{$t("save-changes")}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# Foxnouns.NET
|
# Foxnouns.NET
|
||||||
|
|
||||||
Rewrite of pronouns.cc's codebase in C#, using Remix for the frontend.
|
Rewrite of pronouns.cc's codebase in C#, using SvelteKit for the frontend.
|
||||||
Still very work-in-progress, but a large portion of the backend is functional.
|
Still very work-in-progress, but a large portion of the backend is functional.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
Loading…
Reference in a new issue