153 lines
4.3 KiB
C#
153 lines
4.3 KiB
C#
using System.Text;
|
|
|
|
namespace GmRelay.Web.Services.Portfolio;
|
|
|
|
public static class PortfolioValidation
|
|
{
|
|
private const int MinSlugLength = 3;
|
|
private const int MaxSlugLength = 160;
|
|
private const int MinTitleLength = 2;
|
|
private const int MaxTitleLength = 255;
|
|
private const int MaxDescriptionLength = 5000;
|
|
private const int MinReviewBodyLength = 10;
|
|
private const int MaxReviewBodyLength = 2000;
|
|
|
|
private static readonly HashSet<string> AllowedFormats = new(StringComparer.Ordinal)
|
|
{
|
|
"Online",
|
|
"Offline",
|
|
"Hybrid"
|
|
};
|
|
|
|
public static string NormalizeSlug(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
throw new InvalidOperationException("Slug must not be empty.");
|
|
}
|
|
|
|
var trimmed = value.Trim().ToLowerInvariant();
|
|
|
|
var builder = new StringBuilder(trimmed.Length);
|
|
var previousWasHyphen = false;
|
|
foreach (var raw in trimmed)
|
|
{
|
|
char c;
|
|
if (raw == ' ' || raw == '_' || raw == '-')
|
|
{
|
|
c = '-';
|
|
}
|
|
else if (IsAsciiAlphanumeric(raw))
|
|
{
|
|
c = raw;
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException($"Slug contains unsupported character: '{raw}'.");
|
|
}
|
|
|
|
if (c == '-')
|
|
{
|
|
if (builder.Length == 0 || previousWasHyphen)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
builder.Append('-');
|
|
previousWasHyphen = true;
|
|
}
|
|
else
|
|
{
|
|
builder.Append(c);
|
|
previousWasHyphen = false;
|
|
}
|
|
}
|
|
|
|
while (builder.Length > 0 && builder[^1] == '-')
|
|
{
|
|
builder.Length--;
|
|
}
|
|
|
|
if (builder.Length < MinSlugLength || builder.Length > MaxSlugLength)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Slug length must be between {MinSlugLength} and {MaxSlugLength} characters.");
|
|
}
|
|
|
|
// The normalization loop guarantees the output matches ^[a-z0-9]+(?:-[a-z0-9]+)*$,
|
|
// so no post-loop regex check is required.
|
|
return builder.ToString();
|
|
}
|
|
|
|
public static string NormalizeTitle(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
throw new InvalidOperationException("Title must not be empty.");
|
|
}
|
|
|
|
var trimmed = value.Trim();
|
|
if (trimmed.Length < MinTitleLength || trimmed.Length > MaxTitleLength)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Title length must be between {MinTitleLength} and {MaxTitleLength} characters.");
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
public static string? NormalizeDescription(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var trimmed = value.Trim();
|
|
if (trimmed.Length > MaxDescriptionLength)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Description must be at most {MaxDescriptionLength} characters.");
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
public static string NormalizeReviewBody(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
throw new InvalidOperationException("Review body must not be empty.");
|
|
}
|
|
|
|
var trimmed = value.Trim();
|
|
if (trimmed.Length < MinReviewBodyLength || trimmed.Length > MaxReviewBodyLength)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Review body length must be between {MinReviewBodyLength} and {MaxReviewBodyLength} characters.");
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
public static string? NormalizeFormat(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var trimmed = value.Trim();
|
|
if (!AllowedFormats.Contains(trimmed))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Format must be one of: {string.Join(", ", AllowedFormats)}.");
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
private static bool IsAsciiAlphanumeric(char c) =>
|
|
(c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
|
|
}
|