Skip to content

Commit 4016bb1

Browse files
authored
Merge pull request #55 from procoderji-com/feat/portfolio-shared-rules
feat(portfolio): shared validation rules for portfolios and usernames
2 parents 92dd402 + a284af6 commit 4016bb1

13 files changed

Lines changed: 3535 additions & 0 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using System.Text.Json;
2+
3+
namespace ProCoderJi.Api.Features.Portfolio;
4+
5+
/// <summary>
6+
/// The portfolio rules shared with the frontend, read from <c>shared/portfolio/rules.json</c>
7+
/// (copied to <c>Data/portfolio-rules.json</c> at build time).
8+
/// </summary>
9+
public sealed class PortfolioRules
10+
{
11+
private static readonly JsonSerializerOptions RulesJson = new()
12+
{
13+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
14+
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow
15+
};
16+
17+
public required IReadOnlyList<string> RequiredForPublish { get; init; }
18+
public required IReadOnlyDictionary<string, int> MaxItems { get; init; }
19+
public required IReadOnlyDictionary<string, int> MaxLength { get; init; }
20+
public required IReadOnlyList<string> MultilineFields { get; init; }
21+
public required TextRules Text { get; init; }
22+
public required LinkRules Link { get; init; }
23+
public required string EmailPattern { get; init; }
24+
public required UsernameRules Username { get; init; }
25+
26+
public sealed record TextRules(string ControlCharacters, string HtmlLike);
27+
28+
public sealed record LinkRules(
29+
string Scheme,
30+
string ForbiddenCharacters,
31+
string HostPattern,
32+
IReadOnlyDictionary<string, string[]> HostRestrictions);
33+
34+
public sealed record UsernameRules(
35+
int MinLength,
36+
int MaxLength,
37+
string Pattern,
38+
IReadOnlyList<string> Reserved,
39+
IReadOnlyList<string> BlockedWords);
40+
41+
private sealed record RulesDocument(
42+
int Version,
43+
List<string>? RequiredForPublish,
44+
Dictionary<string, int>? MaxItems,
45+
Dictionary<string, int>? MaxLength,
46+
List<string>? MultilineFields,
47+
TextRules? Text,
48+
LinkRules? Link,
49+
string? EmailPattern,
50+
UsernameRules? Username);
51+
52+
public static PortfolioRules FromJson(string json)
53+
{
54+
RulesDocument? document;
55+
try { document = JsonSerializer.Deserialize<RulesDocument>(json, RulesJson); }
56+
catch (JsonException cause) { throw new InvalidDataException("The shared portfolio rules are not valid JSON.", cause); }
57+
if (document is not { Version: 1 } || document.RequiredForPublish is null || document.MaxItems is null
58+
|| document.MaxLength is null || document.MultilineFields is null || document.Text is null
59+
|| document.Link is null || document.EmailPattern is null || document.Username is null
60+
|| document.Username.Reserved is null || document.Username.BlockedWords is null
61+
|| document.Link.HostRestrictions is null)
62+
throw new InvalidDataException("The shared portfolio rules are incomplete or use an unsupported version.");
63+
return new PortfolioRules
64+
{
65+
RequiredForPublish = document.RequiredForPublish,
66+
MaxItems = document.MaxItems,
67+
MaxLength = document.MaxLength,
68+
MultilineFields = document.MultilineFields,
69+
Text = document.Text,
70+
Link = document.Link,
71+
EmailPattern = document.EmailPattern,
72+
Username = document.Username
73+
};
74+
}
75+
76+
/// <summary>Loads the rules copied next to the application.</summary>
77+
public static PortfolioRules LoadDefault() =>
78+
FromJson(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data", "portfolio-rules.json")));
79+
}
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
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/&lt;username&gt;</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+
}

‎backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
<ItemGroup>
2323
<Content Include="../../../shared/practice/questions.json" Link="Data/questions.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
24+
<Content Include="../../../shared/portfolio/rules.json" Link="Data/portfolio-rules.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
2425
</ItemGroup>
2526

2627
</Project>

0 commit comments

Comments
 (0)