Compare commits

..

4 commits

Author SHA1 Message Date
sam
8b1d5b2c1b
feat(backend): validate custom preferences on save 2024-11-28 17:28:52 +01:00
sam
71b59dbb00
feat: add icon list generation script
this is used to validate icons for custom preferences. it generates both
typescript and c# code
2024-11-27 20:00:28 +01:00
sam
f435ad4cf5
feat(frontend): fields editor 2024-11-27 19:50:45 +01:00
sam
7c52ab759c
tiny readme update 2024-11-25 23:12:19 +01:00
20 changed files with 2996 additions and 644 deletions

View file

@ -3,3 +3,6 @@
resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none
# This is raised for every single property of records returned by endpoints
resharper_not_accessed_positional_property_local_highlighting = none
[*generated.cs]
generated_code = true

View file

@ -27,7 +27,7 @@ public class MetaController : ApiControllerBase
new Limits(
MemberCount: MembersController.MaxMemberCount,
BioLength: ValidationUtils.MaxBioLength,
CustomPreferences: UsersController.MaxCustomPreferences
CustomPreferences: ValidationUtils.MaxCustomPreferences
)
)
);

View file

@ -197,11 +197,11 @@ public class UsersController(
[Authorize("user.update")]
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateCustomPreferencesAsync(
[FromBody] List<CustomPreferencesUpdateRequest> req,
[FromBody] List<CustomPreferenceUpdate> req,
CancellationToken ct = default
)
{
ValidationUtils.Validate(ValidateCustomPreferences(req));
ValidationUtils.Validate(ValidationUtils.ValidateCustomPreferences(req));
var user = await db.ResolveUserAsync(CurrentUser!.Id, ct);
var preferences = user
@ -241,7 +241,7 @@ public class UsersController(
}
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
public class CustomPreferencesUpdateRequest
public class CustomPreferenceUpdate
{
public Snowflake? Id { get; init; }
public required string Icon { get; set; }
@ -251,34 +251,6 @@ public class UsersController(
public bool Favourite { get; set; }
}
public const int MaxCustomPreferences = 25;
private static List<(string, ValidationError?)> ValidateCustomPreferences(
List<CustomPreferencesUpdateRequest> preferences
)
{
var errors = new List<(string, ValidationError?)>();
if (preferences.Count > MaxCustomPreferences)
errors.Add(
(
"custom_preferences",
ValidationError.LengthError(
"Too many custom preferences",
0,
MaxCustomPreferences,
preferences.Count
)
)
);
if (preferences.Count > 50)
return errors;
// TODO: validate individual preferences
return errors;
}
public class UpdateUserRequest : PatchRequest
{
public string? Username { get; init; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
namespace Foxnouns.Backend.Utils;
public static partial class BootstrapIcons
{
public static bool IsValid(string icon) => Icons.Contains(icon);
}

View 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;
}
}

View 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;
}
}

View 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();
}

View file

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

View file

@ -2,3 +2,4 @@
package-lock.json
pnpm-lock.yaml
yarn.lock
src/lib/icons.ts

View 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);

View file

@ -2,14 +2,25 @@
import type { CustomPreference, FieldEntry } from "$api/models";
import IconButton from "$components/IconButton.svelte";
import { t } from "$lib/i18n";
import { InputGroup, InputGroupText } from "@sveltestrap/sveltestrap";
import FieldEntryEditor from "./FieldEntryEditor.svelte";
type Props = {
name: string;
entries: FieldEntry[];
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("");
@ -38,19 +49,45 @@
};
</script>
<h4>{name}</h4>
{#if index !== undefined && move && remove}
<div class="d-flex">
<InputGroup>
<IconButton
icon="chevron-up"
color="secondary"
tooltip={$t("editor.move-field-up")}
onclick={() => move(index, true)}
/>
<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 _, index}
<FieldEntryEditor
{index}
bind:value={entries[index]}
{allPreferences}
{moveValue}
{removeValue}
/>
{#each entries as _, i}
<FieldEntryEditor index={i} bind:value={entries[i]} {allPreferences} {moveValue} {removeValue} />
{/each}
<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")} />
</form>

View file

@ -38,7 +38,7 @@
icon="chevron-down"
color="secondary"
tooltip={$t("editor.move-entry-down")}
onclick={() => moveValue(index, true)}
onclick={() => moveValue(index, false)}
/>
<input type="text" class="form-control" bind:value={value.value} />
<ButtonDropdown>

View file

@ -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>

View file

@ -1,150 +1,157 @@
{
"hello": "Hello, {{name}}!",
"nav": {
"log-in": "Log in or sign up",
"settings": "Settings"
},
"avatar-tooltip": "Avatar for {{name}}",
"profile": {
"edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.",
"edit-user-profile-notice": "You are currently viewing your public profile.",
"edit-profile-link": "Edit profile",
"names-header": "Names",
"pronouns-header": "Pronouns",
"default-members-header": "Members",
"create-member-button": "Create member",
"back-to-user": "Back to {{name}}"
},
"title": {
"log-in": "Log in",
"welcome": "Welcome",
"settings": "Settings"
},
"auth": {
"log-in-form-title": "Log in with email",
"log-in-form-email-label": "Email address",
"log-in-form-password-label": "Password",
"register-with-email-button": "Register with email",
"log-in-button": "Log in",
"log-in-3rd-party-header": "Log in with another service",
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
"log-in-with-discord": "Log in with Discord",
"log-in-with-google": "Log in with Google",
"log-in-with-tumblr": "Log in with Tumblr",
"log-in-with-the-fediverse": "Log in with the Fediverse",
"remote-fediverse-account-label": "Your Fediverse account",
"register-username-label": "Username",
"register-button": "Register account",
"register-with-mastodon": "Register with a Fediverse account",
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end"
},
"error": {
"bad-request-header": "Something was wrong with your input",
"generic-header": "Something went wrong",
"raw-header": "Raw error",
"authentication-error": "Something went wrong when logging you in.",
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
"forbidden": "You are not allowed to perform that action.",
"internal-server-error": "Server experienced an internal error, please try again later.",
"authentication-required": "You need to log in first.",
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
"generic-error": "An unknown error occurred.",
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
"member-not-found": "Member not found, please check your spelling and try again.",
"account-already-linked": "This account is already linked with a pronouns.cc account.",
"last-auth-method": "You cannot remove your last authentication method.",
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
"validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.",
"validation-disallowed-value-1": "The following value is not allowed here",
"validation-disallowed-value-2": "Allowed values are",
"validation-reason": "Reason",
"validation-generic": "The value you entered is not allowed here. Reason",
"extra-info-header": "Extra error information",
"noscript-title": "This page requires JavaScript",
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
"noscript-short": "Requires JavaScript"
},
"settings": {
"general-information-tab": "General information",
"your-profile-tab": "Your profile",
"members-tab": "Members",
"authentication-tab": "Authentication",
"export-tab": "Export your data",
"change-username-button": "Change username",
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
"change-avatar-link": "Change your avatar here",
"new-username": "New username",
"table-role": "Role",
"table-custom-preferences": "Custom preferences",
"table-member-list-hidden": "Member list hidden?",
"table-member-count": "Member count",
"table-created-at": "Account created at",
"table-id": "Your ID",
"table-title": "Account information",
"force-log-out-title": "Log out everywhere",
"force-log-out-button": "Force log out",
"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
"log-out-title": "Log out",
"log-out-hint": "Use this button to log out on this device only.",
"log-out-button": "Log out",
"avatar": "Avatar",
"username-update-success": "Successfully changed your username!",
"create-member-title": "Create a new member",
"create-member-name-label": "Member name"
},
"yes": "Yes",
"no": "No",
"edit-profile": {
"user-header": "Editing your profile",
"general-tab": "General",
"names-pronouns-tab": "Names & pronouns",
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
"sid-current": "Current short ID:",
"sid": "Short ID",
"sid-reroll": "Reroll short ID",
"sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.",
"sid-copy": "Copy short link",
"update-avatar": "Update avatar",
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
"member-header-label": "\"Members\" header text",
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
"hide-member-list-label": "Hide member list",
"timezone-label": "Timezone",
"timezone-preview": "This will show up on your profile like this:",
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
"hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.",
"profile-options-header": "Profile options",
"bio-tab": "Bio",
"saved-changes": "Successfully saved changes!",
"bio-length-hint": "Using {{length}}/{{maxLength}} characters",
"preview": "Preview",
"fields-tab": "Fields",
"flags-links-tab": "Flags & links",
"back-to-settings-tab": "Back to settings",
"member-header": "Editing profile of {{name}}",
"username": "Username",
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.",
"change-username-link": "Go to settings",
"member-name": "Name",
"change-member-name": "Change name",
"display-name": "Display name",
"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:",
"edit-names-pronouns-header": "Edit names and pronouns",
"back-to-profile-tab": "Back to profile"
},
"save-changes": "Save changes",
"change": "Change",
"editor": {
"remove-entry": "Remove entry",
"move-entry-down": "Move entry down",
"move-entry-up": "Move entry up",
"add-entry": "Add entry",
"change-display-text": "Change display text",
"display-text-example": "Optional display text (e.g. it/its)",
"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."
}
"hello": "Hello, {{name}}!",
"nav": {
"log-in": "Log in or sign up",
"settings": "Settings"
},
"avatar-tooltip": "Avatar for {{name}}",
"profile": {
"edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.",
"edit-user-profile-notice": "You are currently viewing your public profile.",
"edit-profile-link": "Edit profile",
"names-header": "Names",
"pronouns-header": "Pronouns",
"default-members-header": "Members",
"create-member-button": "Create member",
"back-to-user": "Back to {{name}}"
},
"title": {
"log-in": "Log in",
"welcome": "Welcome",
"settings": "Settings"
},
"auth": {
"log-in-form-title": "Log in with email",
"log-in-form-email-label": "Email address",
"log-in-form-password-label": "Password",
"register-with-email-button": "Register with email",
"log-in-button": "Log in",
"log-in-3rd-party-header": "Log in with another service",
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
"log-in-with-discord": "Log in with Discord",
"log-in-with-google": "Log in with Google",
"log-in-with-tumblr": "Log in with Tumblr",
"log-in-with-the-fediverse": "Log in with the Fediverse",
"remote-fediverse-account-label": "Your Fediverse account",
"register-username-label": "Username",
"register-button": "Register account",
"register-with-mastodon": "Register with a Fediverse account",
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end"
},
"error": {
"bad-request-header": "Something was wrong with your input",
"generic-header": "Something went wrong",
"raw-header": "Raw error",
"authentication-error": "Something went wrong when logging you in.",
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
"forbidden": "You are not allowed to perform that action.",
"internal-server-error": "Server experienced an internal error, please try again later.",
"authentication-required": "You need to log in first.",
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
"generic-error": "An unknown error occurred.",
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
"member-not-found": "Member not found, please check your spelling and try again.",
"account-already-linked": "This account is already linked with a pronouns.cc account.",
"last-auth-method": "You cannot remove your last authentication method.",
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
"validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.",
"validation-disallowed-value-1": "The following value is not allowed here",
"validation-disallowed-value-2": "Allowed values are",
"validation-reason": "Reason",
"validation-generic": "The value you entered is not allowed here. Reason",
"extra-info-header": "Extra error information",
"noscript-title": "This page requires JavaScript",
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
"noscript-short": "Requires JavaScript"
},
"settings": {
"general-information-tab": "General information",
"your-profile-tab": "Your profile",
"members-tab": "Members",
"authentication-tab": "Authentication",
"export-tab": "Export your data",
"change-username-button": "Change username",
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
"change-avatar-link": "Change your avatar here",
"new-username": "New username",
"table-role": "Role",
"table-custom-preferences": "Custom preferences",
"table-member-list-hidden": "Member list hidden?",
"table-member-count": "Member count",
"table-created-at": "Account created at",
"table-id": "Your ID",
"table-title": "Account information",
"force-log-out-title": "Log out everywhere",
"force-log-out-button": "Force log out",
"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
"log-out-title": "Log out",
"log-out-hint": "Use this button to log out on this device only.",
"log-out-button": "Log out",
"avatar": "Avatar",
"username-update-success": "Successfully changed your username!",
"create-member-title": "Create a new member",
"create-member-name-label": "Member name"
},
"yes": "Yes",
"no": "No",
"edit-profile": {
"user-header": "Editing your profile",
"general-tab": "General",
"names-pronouns-tab": "Names & pronouns",
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
"sid-current": "Current short ID:",
"sid": "Short ID",
"sid-reroll": "Reroll short ID",
"sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.",
"sid-copy": "Copy short link",
"update-avatar": "Update avatar",
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
"member-header-label": "\"Members\" header text",
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
"hide-member-list-label": "Hide member list",
"timezone-label": "Timezone",
"timezone-preview": "This will show up on your profile like this:",
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
"hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.",
"profile-options-header": "Profile options",
"bio-tab": "Bio",
"saved-changes": "Successfully saved changes!",
"bio-length-hint": "Using {{length}}/{{maxLength}} characters",
"preview": "Preview",
"fields-tab": "Fields",
"flags-links-tab": "Flags & links",
"back-to-settings-tab": "Back to settings",
"member-header": "Editing profile of {{name}}",
"username": "Username",
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.",
"change-username-link": "Go to settings",
"member-name": "Name",
"change-member-name": "Change name",
"display-name": "Display name",
"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:",
"edit-names-pronouns-header": "Edit names and pronouns",
"back-to-profile-tab": "Back to profile",
"editing-fields-header": "Editing fields"
},
"save-changes": "Save changes",
"change": "Change",
"editor": {
"remove-entry": "Remove entry",
"move-entry-down": "Move entry down",
"move-entry-up": "Move entry up",
"add-entry": "Add entry",
"change-display-text": "Change display text",
"display-text-example": "Optional display text (e.g. it/its)",
"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.",
"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"
}
}

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,7 @@
import { mergePreferences } from "$api/models/user";
import FieldEditor from "$components/editor/FieldEditor.svelte";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import NoscriptWarning from "$components/editor/NoscriptWarning.svelte";
import PronounsEditor from "$components/editor/PronounsEditor.svelte";
import { t } from "$lib/i18n";
import log from "$lib/log";
@ -37,16 +38,15 @@
};
</script>
<NoscriptWarning />
<FormStatusMarker form={ok} />
<div>
<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} />
</div>
<div>
<PronounsEditor bind:entries={pronouns} {allPreferences} />
</div>
<div>
<button class="btn btn-primary" onclick={() => update()}>{$t("save-changes")}</button>
</div>

View file

@ -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} />

View file

@ -4,6 +4,7 @@
import { mergePreferences, type User } from "$api/models/user";
import FieldEditor from "$components/editor/FieldEditor.svelte";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import NoscriptWarning from "$components/editor/NoscriptWarning.svelte";
import PronounsEditor from "$components/editor/PronounsEditor.svelte";
import { t } from "$lib/i18n";
import log from "$lib/log";
@ -14,9 +15,7 @@
let names = $state(data.user.names);
let pronouns = $state(data.user.pronouns);
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
let allPreferences = $derived(mergePreferences(data.user.custom_preferences));
const update = async () => {
@ -36,16 +35,15 @@
};
</script>
<NoscriptWarning />
<FormStatusMarker form={ok} />
<div>
<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} />
</div>
<div>
<PronounsEditor bind:entries={pronouns} {allPreferences} />
</div>
<div>
<button class="btn btn-primary" onclick={() => update()}>{$t("save-changes")}</button>
</div>

View file

@ -1,6 +1,6 @@
# 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.
## License