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 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'); }