|
| 1 | +using System.Text.Json.Nodes; |
| 2 | +using System.Text.RegularExpressions; |
| 3 | + |
| 4 | +namespace ProCoderJi.Api.Features.Portfolio; |
| 5 | + |
| 6 | +public enum PortfolioMode { Draft, Publish } |
| 7 | + |
| 8 | +/// <summary>A problem at a field path such as <c>projects[0].link</c>.</summary> |
| 9 | +public readonly record struct PortfolioProblem(string Path, string Code) |
| 10 | +{ |
| 11 | + /// <summary>The stable <c>path:code</c> form used by the shared fixtures.</summary> |
| 12 | + public override string ToString() => $"{Path}:{Code}"; |
| 13 | +} |
| 14 | + |
| 15 | +/// <summary> |
| 16 | +/// Trusted portfolio validation. The frontend implements the same rules in |
| 17 | +/// <c>src/features/portfolio/validation.ts</c>, and both run against |
| 18 | +/// <c>shared/portfolio/fixtures.json</c>. Keep the two in step: when a check changes, add a |
| 19 | +/// fixture first. The rules avoid URI parsers and culture-aware string functions on purpose, |
| 20 | +/// because those behave differently in .NET and JavaScript. |
| 21 | +/// </summary> |
| 22 | +public sealed class PortfolioValidator |
| 23 | +{ |
| 24 | + private static readonly string[] TopLevelFields = |
| 25 | + ["name", "headline", "about", "skills", "projects", "education", "experience", "achievements", "links"]; |
| 26 | + |
| 27 | + private readonly PortfolioRules rules; |
| 28 | + private readonly Regex control; |
| 29 | + private readonly Regex htmlLike; |
| 30 | + private readonly Regex linkForbidden; |
| 31 | + private readonly Regex host; |
| 32 | + private readonly Regex email; |
| 33 | + private readonly Regex username; |
| 34 | + private readonly HashSet<string> reserved; |
| 35 | + private readonly HashSet<string> multiline; |
| 36 | + |
| 37 | + public PortfolioValidator(PortfolioRules rules) |
| 38 | + { |
| 39 | + this.rules = rules; |
| 40 | + control = Pattern(rules.Text.ControlCharacters); |
| 41 | + htmlLike = Pattern(rules.Text.HtmlLike); |
| 42 | + linkForbidden = Pattern(rules.Link.ForbiddenCharacters); |
| 43 | + host = Anchored(rules.Link.HostPattern); |
| 44 | + email = Anchored(rules.EmailPattern); |
| 45 | + username = Anchored(rules.Username.Pattern); |
| 46 | + reserved = new HashSet<string>(rules.Username.Reserved, StringComparer.Ordinal); |
| 47 | + multiline = new HashSet<string>(rules.MultilineFields, StringComparer.Ordinal); |
| 48 | + } |
| 49 | + |
| 50 | + public static PortfolioValidator CreateDefault() => new(PortfolioRules.LoadDefault()); |
| 51 | + |
| 52 | + // .NET's "$" also matches before a final newline; "\z" does not, so "riya\n" cannot slip through. |
| 53 | + private static Regex Anchored(string pattern) => new(pattern + "\\z", RegexOptions.CultureInvariant); |
| 54 | + private static Regex Pattern(string pattern) => new(pattern, RegexOptions.CultureInvariant); |
| 55 | + |
| 56 | + public IReadOnlyList<PortfolioProblem> ValidateUsername(string value) => |
| 57 | + ValidateUsername(JsonValue.Create(value)); |
| 58 | + |
| 59 | + /// <summary>Checks a username for the public address <c>/u/<username></c>.</summary> |
| 60 | + public IReadOnlyList<PortfolioProblem> ValidateUsername(JsonNode? node) |
| 61 | + { |
| 62 | + const string path = "username"; |
| 63 | + var rule = rules.Username; |
| 64 | + var read = new List<PortfolioProblem>(); |
| 65 | + if (!Read(read, path, node, out var value)) return read; |
| 66 | + if (IsBlank(value)) return [new(path, "required")]; |
| 67 | + if (value.Length < rule.MinLength) return [new(path, "too-short")]; |
| 68 | + if (value.Length > rule.MaxLength) return [new(path, "too-long")]; |
| 69 | + if (!username.IsMatch(value)) return [new(path, "invalid-format")]; |
| 70 | + if (reserved.Contains(value)) return [new(path, "reserved")]; |
| 71 | + var squashed = value.Replace("-", "", StringComparison.Ordinal); |
| 72 | + if (rule.BlockedWords.Any(word => squashed.Contains(word, StringComparison.Ordinal))) |
| 73 | + return [new(path, "blocked-word")]; |
| 74 | + return []; |
| 75 | + } |
| 76 | + |
| 77 | + /// <summary> |
| 78 | + /// Checks untrusted portfolio data. A draft only has to be well-formed; publishing also needs |
| 79 | + /// the fields listed in <c>requiredForPublish</c>. An empty result means the data is valid. |
| 80 | + /// </summary> |
| 81 | + public IReadOnlyList<PortfolioProblem> Validate(JsonNode? node, PortfolioMode mode) |
| 82 | + { |
| 83 | + var problems = new List<PortfolioProblem>(); |
| 84 | + if (node is not JsonObject root) return [new("$", "invalid-type")]; |
| 85 | + if (!CheckUnknownFields(problems, "", root, TopLevelFields)) return problems; |
| 86 | + |
| 87 | + bool RequiredOnPublish(string key) => mode == PortfolioMode.Publish && rules.RequiredForPublish.Contains(key); |
| 88 | + CheckText(problems, "name", Get(root, "name"), "name", RequiredOnPublish("name")); |
| 89 | + CheckText(problems, "headline", Get(root, "headline"), "headline", RequiredOnPublish("headline")); |
| 90 | + CheckText(problems, "about", Get(root, "about"), "about", false); |
| 91 | + |
| 92 | + CheckList(problems, "skills", Get(root, "skills"), "skills", (path, item) => |
| 93 | + CheckText(problems, path, new(true, item), "skills.item", true)); |
| 94 | + |
| 95 | + CheckList(problems, "projects", Get(root, "projects"), "projects", (path, item) => |
| 96 | + CheckObjectItem(problems, path, item, ["title", "link", "technologies", "description"], project => |
| 97 | + { |
| 98 | + CheckText(problems, $"{path}.title", Get(project, "title"), "projects.title", true); |
| 99 | + CheckLink(problems, $"{path}.link", Get(project, "link"), null); |
| 100 | + CheckList(problems, $"{path}.technologies", Get(project, "technologies"), "projects.technologies", (techPath, tech) => |
| 101 | + CheckText(problems, techPath, new(true, tech), "projects.technologies.item", true)); |
| 102 | + CheckText(problems, $"{path}.description", Get(project, "description"), "projects.description", false); |
| 103 | + })); |
| 104 | + |
| 105 | + CheckList(problems, "education", Get(root, "education"), "education", (path, item) => |
| 106 | + CheckObjectItem(problems, path, item, ["institution", "degree", "period", "details"], entry => |
| 107 | + { |
| 108 | + CheckText(problems, $"{path}.institution", Get(entry, "institution"), "education.institution", true); |
| 109 | + CheckText(problems, $"{path}.degree", Get(entry, "degree"), "education.degree", false); |
| 110 | + CheckText(problems, $"{path}.period", Get(entry, "period"), "education.period", false); |
| 111 | + CheckText(problems, $"{path}.details", Get(entry, "details"), "education.details", false); |
| 112 | + })); |
| 113 | + |
| 114 | + CheckList(problems, "experience", Get(root, "experience"), "experience", (path, item) => |
| 115 | + CheckObjectItem(problems, path, item, ["organization", "role", "period", "description"], entry => |
| 116 | + { |
| 117 | + CheckText(problems, $"{path}.organization", Get(entry, "organization"), "experience.organization", true); |
| 118 | + CheckText(problems, $"{path}.role", Get(entry, "role"), "experience.role", true); |
| 119 | + CheckText(problems, $"{path}.period", Get(entry, "period"), "experience.period", false); |
| 120 | + CheckText(problems, $"{path}.description", Get(entry, "description"), "experience.description", false); |
| 121 | + })); |
| 122 | + |
| 123 | + CheckList(problems, "achievements", Get(root, "achievements"), "achievements", (path, item) => |
| 124 | + CheckText(problems, path, new(true, item), "achievements.item", true)); |
| 125 | + |
| 126 | + var links = Get(root, "links"); |
| 127 | + if (links.Present) |
| 128 | + { |
| 129 | + if (links.Node is not JsonObject linkObject) problems.Add(new("links", "invalid-type")); |
| 130 | + else if (CheckUnknownFields(problems, "links", linkObject, ["github", "linkedin", "website", "email"])) |
| 131 | + { |
| 132 | + CheckLink(problems, "links.github", Get(linkObject, "github"), "links.github"); |
| 133 | + CheckLink(problems, "links.linkedin", Get(linkObject, "linkedin"), "links.linkedin"); |
| 134 | + CheckLink(problems, "links.website", Get(linkObject, "website"), null); |
| 135 | + CheckEmail(problems, "links.email", Get(linkObject, "email")); |
| 136 | + } |
| 137 | + } |
| 138 | + return problems; |
| 139 | + } |
| 140 | + |
| 141 | + private readonly record struct Field(bool Present, JsonNode? Node); |
| 142 | + |
| 143 | + private static Field Get(JsonObject source, string key) => |
| 144 | + source.TryGetPropertyValue(key, out var node) ? new(true, node) : new(false, null); |
| 145 | + |
| 146 | + private enum StringRead { Ok, NotAString, Malformed } |
| 147 | + |
| 148 | + /// <summary> |
| 149 | + /// Reads a JSON string without throwing. Depending on the runtime, a lazily decoded string |
| 150 | + /// that holds a lone surrogate throws while it is read; that and a programmatically built |
| 151 | + /// string with a lone surrogate are both reported as malformed. |
| 152 | + /// </summary> |
| 153 | + private static StringRead TryString(JsonNode? node, out string value) |
| 154 | + { |
| 155 | + value = ""; |
| 156 | + if (node is not JsonValue json) return StringRead.NotAString; |
| 157 | + string? text; |
| 158 | + try |
| 159 | + { |
| 160 | + if (!json.TryGetValue<string>(out text) || text is null) return StringRead.NotAString; |
| 161 | + } |
| 162 | + catch (Exception cause) when (cause is InvalidOperationException or ArgumentException or FormatException) |
| 163 | + { |
| 164 | + return StringRead.Malformed; |
| 165 | + } |
| 166 | + if (!IsWellFormed(text)) return StringRead.Malformed; |
| 167 | + value = text; |
| 168 | + return StringRead.Ok; |
| 169 | + } |
| 170 | + |
| 171 | + /// <summary>Adds the problem for an unreadable value; returns true when the value was read.</summary> |
| 172 | + private static bool Read(List<PortfolioProblem> problems, string path, JsonNode? node, out string value) |
| 173 | + { |
| 174 | + switch (TryString(node, out value)) |
| 175 | + { |
| 176 | + case StringRead.Ok: |
| 177 | + return true; |
| 178 | + case StringRead.Malformed: |
| 179 | + problems.Add(new(path, "invalid-characters")); |
| 180 | + return false; |
| 181 | + default: |
| 182 | + problems.Add(new(path, "invalid-type")); |
| 183 | + return false; |
| 184 | + } |
| 185 | + } |
| 186 | + |
| 187 | + /// <summary>False when the text has a lone surrogate half, which no encoding can represent.</summary> |
| 188 | + private static bool IsWellFormed(string text) |
| 189 | + { |
| 190 | + for (var i = 0; i < text.Length; i++) |
| 191 | + { |
| 192 | + if (char.IsHighSurrogate(text[i])) |
| 193 | + { |
| 194 | + if (i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1])) return false; |
| 195 | + i++; |
| 196 | + } |
| 197 | + else if (char.IsLowSurrogate(text[i])) return false; |
| 198 | + } |
| 199 | + return true; |
| 200 | + } |
| 201 | + |
| 202 | + private static bool IsBlank(string value) => value.All(c => c is ' ' or '\t' or '\r' or '\n'); |
| 203 | + |
| 204 | + /// <summary>Lower-cases A-Z only, so other characters such as the Kelvin sign are never folded.</summary> |
| 205 | + private static string AsciiLower(string value) => |
| 206 | + string.Create(value.Length, value, static (span, source) => |
| 207 | + { |
| 208 | + for (var i = 0; i < source.Length; i++) |
| 209 | + span[i] = source[i] is >= 'A' and <= 'Z' ? (char)(source[i] + 32) : source[i]; |
| 210 | + }); |
| 211 | + |
| 212 | + private int Limit(IReadOnlyDictionary<string, int> table, string key) => |
| 213 | + table.TryGetValue(key, out var value) ? value : throw new InvalidDataException($"Missing portfolio rule: {key}"); |
| 214 | + |
| 215 | + /// <summary> |
| 216 | + /// Reports unknown field names. Returns false, after reporting <c>invalid-characters</c> for the |
| 217 | + /// object itself, when a field name is not well-formed text; the caller then skips that object. |
| 218 | + /// System.Text.Json decodes property names lazily, so a lone surrogate escape in a parsed name |
| 219 | + /// throws the first time the object is read. |
| 220 | + /// </summary> |
| 221 | + private static bool CheckUnknownFields(List<PortfolioProblem> problems, string path, JsonObject source, IReadOnlyCollection<string> allowed) |
| 222 | + { |
| 223 | + List<string> keys; |
| 224 | + try { keys = source.Select(property => property.Key).ToList(); } |
| 225 | + catch (Exception cause) when (cause is InvalidOperationException or ArgumentException or FormatException) |
| 226 | + { |
| 227 | + problems.Add(new(path.Length == 0 ? "$" : path, "invalid-characters")); |
| 228 | + return false; |
| 229 | + } |
| 230 | + if (keys.Any(key => !IsWellFormed(key))) |
| 231 | + { |
| 232 | + problems.Add(new(path.Length == 0 ? "$" : path, "invalid-characters")); |
| 233 | + return false; |
| 234 | + } |
| 235 | + foreach (var key in keys) |
| 236 | + if (!allowed.Contains(key)) problems.Add(new(path.Length == 0 ? key : $"{path}.{key}", "unknown-field")); |
| 237 | + return true; |
| 238 | + } |
| 239 | + |
| 240 | + private void CheckText(List<PortfolioProblem> problems, string path, Field field, string key, bool required) |
| 241 | + { |
| 242 | + if (!field.Present) |
| 243 | + { |
| 244 | + if (required) problems.Add(new(path, "required")); |
| 245 | + return; |
| 246 | + } |
| 247 | + if (!Read(problems, path, field.Node, out var value)) return; |
| 248 | + // Length comes before the blank check, so a field of only spaces cannot exceed its limit. |
| 249 | + if (value.Length > Limit(rules.MaxLength, key)) { problems.Add(new(path, "too-long")); return; } |
| 250 | + if (IsBlank(value)) |
| 251 | + { |
| 252 | + if (required) problems.Add(new(path, "required")); |
| 253 | + return; |
| 254 | + } |
| 255 | + if (control.IsMatch(value) || (!multiline.Contains(key) && value.AsSpan().IndexOfAny('\r', '\n') >= 0)) |
| 256 | + { |
| 257 | + problems.Add(new(path, "invalid-characters")); |
| 258 | + return; |
| 259 | + } |
| 260 | + if (htmlLike.IsMatch(value)) problems.Add(new(path, "html-like")); |
| 261 | + } |
| 262 | + |
| 263 | + /// <summary>An optional https link. <paramref name="hostKey"/> selects the host restriction, such as <c>links.github</c>.</summary> |
| 264 | + private void CheckLink(List<PortfolioProblem> problems, string path, Field field, string? hostKey) |
| 265 | + { |
| 266 | + if (!field.Present) return; |
| 267 | + if (!Read(problems, path, field.Node, out var value)) return; |
| 268 | + if (value.Length > Limit(rules.MaxLength, "link")) { problems.Add(new(path, "too-long")); return; } |
| 269 | + if (IsBlank(value)) return; |
| 270 | + var scheme = rules.Link.Scheme; |
| 271 | + if (linkForbidden.IsMatch(value) || value.Length < scheme.Length || AsciiLower(value[..scheme.Length]) != scheme) |
| 272 | + { |
| 273 | + problems.Add(new(path, "invalid-link")); |
| 274 | + return; |
| 275 | + } |
| 276 | + var rest = value[scheme.Length..]; |
| 277 | + var end = rest.AsSpan().IndexOfAny('/', '?', '#'); |
| 278 | + var authority = end < 0 ? rest : rest[..end]; |
| 279 | + // The host pattern allows only letters, digits, hyphens and dots, so credentials |
| 280 | + // (user@host), ports (host:8443), IP literals and empty hosts all fail it. |
| 281 | + var hostName = AsciiLower(authority); |
| 282 | + if (!host.IsMatch(hostName)) { problems.Add(new(path, "invalid-link")); return; } |
| 283 | + if (hostKey is not null && rules.Link.HostRestrictions.TryGetValue(hostKey, out var allowed) |
| 284 | + && !allowed.Any(domain => hostName == domain || hostName.EndsWith("." + domain, StringComparison.Ordinal))) |
| 285 | + problems.Add(new(path, "invalid-host")); |
| 286 | + } |
| 287 | + |
| 288 | + private void CheckEmail(List<PortfolioProblem> problems, string path, Field field) |
| 289 | + { |
| 290 | + if (!field.Present) return; |
| 291 | + if (!Read(problems, path, field.Node, out var value)) return; |
| 292 | + if (value.Length > Limit(rules.MaxLength, "email")) { problems.Add(new(path, "too-long")); return; } |
| 293 | + if (IsBlank(value)) return; |
| 294 | + if (!email.IsMatch(value)) problems.Add(new(path, "invalid-email")); |
| 295 | + } |
| 296 | + |
| 297 | + private void CheckList(List<PortfolioProblem> problems, string path, Field field, string key, Action<string, JsonNode?> checkItem) |
| 298 | + { |
| 299 | + if (!field.Present) return; |
| 300 | + if (field.Node is not JsonArray array) { problems.Add(new(path, "invalid-type")); return; } |
| 301 | + if (array.Count > Limit(rules.MaxItems, key)) { problems.Add(new(path, "too-many")); return; } |
| 302 | + for (var i = 0; i < array.Count; i++) checkItem($"{path}[{i}]", array[i]); |
| 303 | + } |
| 304 | + |
| 305 | + private static void CheckObjectItem(List<PortfolioProblem> problems, string path, JsonNode? item, string[] allowed, Action<JsonObject> checkFields) |
| 306 | + { |
| 307 | + if (item is not JsonObject source) { problems.Add(new(path, "invalid-type")); return; } |
| 308 | + if (CheckUnknownFields(problems, path, source, allowed)) checkFields(source); |
| 309 | + } |
| 310 | +} |
0 commit comments