From aa98f3ab0b74d87372c2da07041ca090243273a4 Mon Sep 17 00:00:00 2001
From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com>
Date: Tue, 10 Nov 2026 15:15:37 +0530
Subject: [PATCH 1/4] feat(portfolio): add shared rules and fixtures
Define the student-portfolio rules once in shared/portfolio/rules.json:
field limits, https-only link and host patterns, email pattern, and
the username format with reserved names and blocked words. Add
fixtures.json with 144 username and portfolio cases that both
validators must agree on, and a README that documents the limits and
how to change a rule.
Refs #42
---
docs/development.md | 3 +
shared/portfolio/README.md | 91 ++
shared/portfolio/fixtures.json | 1916 ++++++++++++++++++++++++++++++++
shared/portfolio/rules.json | 67 ++
4 files changed, 2077 insertions(+)
create mode 100644 shared/portfolio/README.md
create mode 100644 shared/portfolio/fixtures.json
create mode 100644 shared/portfolio/rules.json
diff --git a/docs/development.md b/docs/development.md
index 4649b95..fd82fd3 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -102,6 +102,9 @@ by Git.
synchronization.
- `shared/practice/questions.json` is the canonical question bank consumed by the frontend and
backend. Keep IDs stable when improving wording; use a new ID when a question's meaning changes.
+- `shared/portfolio/` holds the student-portfolio rules and test fixtures used by both runtimes
+ (see its [README](../shared/portfolio/README.md)); the validators live in
+ `src/features/portfolio/` and `backend/src/ProCoderJi.Api/Features/Portfolio/`.
- `backend/src/ProCoderJi.Api/` owns Google sign-in, cookie/antiforgery handling, account progress,
and EF persistence. Backend tests live under `backend/tests/`.
- `src/content/` and `src/data/` publish reviewed notes, guides, and listings; see the
diff --git a/shared/portfolio/README.md b/shared/portfolio/README.md
new file mode 100644
index 0000000..dfde2e6
--- /dev/null
+++ b/shared/portfolio/README.md
@@ -0,0 +1,91 @@
+# Portfolio rules
+
+The rules for student portfolios, shared by the frontend and the backend. The decisions behind
+them are in [ADR 0002](../../docs/decisions/0002-student-portfolios.md).
+
+| File | Purpose |
+| --- | --- |
+| `rules.json` | Limits, reserved names, blocked words, and the patterns for links, hosts and email. The only place these numbers live. |
+| `fixtures.json` | Example usernames and portfolios with the problems each must produce. Both validators run all of them. |
+
+The TypeScript validator is `src/features/portfolio/validation.ts`; the .NET validator is
+`backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs`. The TypeScript validator
+gives quick feedback in the editor; the backend is the trusted check before anything is stored.
+
+## What is checked
+
+A portfolio has fixed fields: `name`, `headline`, `about`, `skills`, `projects`, `education`,
+`experience`, `achievements`, and `links` (`github`, `linkedin`, `website`, `email`). Anything else is
+rejected as an `unknown-field`.
+
+- **Modes.** A *draft* only has to be well-formed, so a student can save half-finished work. *Publish* also
+ requires `name` and `headline`. Entries inside a list (a project's title, an
+ experience's role, a skill) are required whenever the entry exists.
+- **Text.** No control or invisible characters (such as zero-width spaces or right-to-left overrides). Only
+ `about`, `projects.description`, `education.details`, `experience.description` may contain line breaks. Text that looks like an HTML tag
+ (a `<` directly followed by a letter, `/`, `!` or `?`) is rejected, so write `x < y` with spaces.
+- **Links.** `https` only, no credentials, no port, no IP address, and no `localhost`. `links.github` must be on
+ `github.com` and `links.linkedin` on `linkedin.com` (subdomains are fine). The `email` field holds an
+ address such as `riya@example.com`; the page adds the `mailto:` link.
+- **Username.** 3 to 30 characters: lowercase letters and digits, with single hyphens between
+ them. Reserved names and names containing a blocked word are refused. Hyphens are ignored when looking for a
+ blocked word, so `pay-tm` is refused too. This is deliberately strict: a harmless name such as `bank-guide` is
+ refused as well.
+
+Each problem is reported as a path and a code, written `path:code`, for example `projects[0].link:invalid-link`.
+The codes are `required`, `invalid-type`, `unknown-field`, `too-short`, `too-long`, `too-many`,
+`invalid-characters`, `html-like`, `invalid-format`, `invalid-link`, `invalid-host`, `invalid-email`,
+`reserved` and `blocked-word`. Only the first failing check is reported for each field.
+
+## Limits
+
+Lengths count UTF-16 code units, the unit both JavaScript and .NET use, so one emoji counts as 2.
+
+| Field | Maximum length |
+| --- | --- |
+| `name` | 80 |
+| `headline` | 120 |
+| `about` | 1000 |
+| `skills.item` | 40 |
+| `projects.title` | 80 |
+| `projects.description` | 500 |
+| `projects.technologies.item` | 40 |
+| `education.institution` | 120 |
+| `education.degree` | 120 |
+| `education.period` | 40 |
+| `education.details` | 300 |
+| `experience.organization` | 120 |
+| `experience.role` | 120 |
+| `experience.period` | 40 |
+| `experience.description` | 500 |
+| `achievements.item` | 200 |
+| `link` | 300 |
+| `email` | 254 |
+
+| List | Maximum entries |
+| --- | --- |
+| `skills` | 30 |
+| `projects` | 8 |
+| `projects.technologies` | 12 |
+| `education` | 5 |
+| `experience` | 6 |
+| `achievements` | 10 |
+
+## Reserved names
+
+`u`, `www`, `api`, `app`, `admin`, `administrator`, `root`, `mail`, `email`, `smtp`, `ftp`, `docs`, `blog`, `login`, `logout`, `signin`, `signup`, `register`, `auth`, `account`, `support`, `help`, `contact`, `about`, `status`, `static`, `assets`, `cdn`, `dev`, `test`, `staging`, `preview`, `dashboard`, `courses`, `learn`, `practice`, `games`, `guides`, `jobs`, `privacy`, `terms`, `disclaimer`, `copyright`, `portfolio`, `portfolios`, `procoderji`, `satyam`, `robots`, `sitemap`, `favicon`, `icon`, `apple-icon`, `logo`, `brand`, `og`, `new`, `edit`, `settings`, `profile`, `me`, `null`, `undefined`
+
+## Blocked words
+
+`paytm`, `phonepe`, `razorpay`, `bank`, `login`, `verify`, `password`, `otp`, `wallet`
+
+## Changing a rule
+
+1. Add or change a case in `fixtures.json` first, with the `path:code` problems you expect.
+2. Change `rules.json` if the change is a number, a name or a pattern.
+3. Change both validators if the logic changes, and keep them identical in behaviour. The validators avoid URL
+ parsers and locale-aware string functions on purpose, because JavaScript and .NET disagree about them.
+4. Run `npm test` and `dotnet test backend/ProCoderJi.slnx --configuration Release`. Both must pass.
+
+The fixture file is plain ASCII: write any other character as a Unicode escape (a backslash, `u`, and four hex
+digits) so reviewers can see exactly which character it is.
diff --git a/shared/portfolio/fixtures.json b/shared/portfolio/fixtures.json
new file mode 100644
index 0000000..b63fa65
--- /dev/null
+++ b/shared/portfolio/fixtures.json
@@ -0,0 +1,1916 @@
+{
+ "usernames": [
+ {
+ "name": "simple name",
+ "value": "riya",
+ "problems": []
+ },
+ {
+ "name": "hyphenated name",
+ "value": "riya-sharma",
+ "problems": []
+ },
+ {
+ "name": "digits",
+ "value": "a1b2c3",
+ "problems": []
+ },
+ {
+ "name": "shortest allowed",
+ "value": "abc",
+ "problems": []
+ },
+ {
+ "name": "longest allowed (30)",
+ "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "problems": []
+ },
+ {
+ "name": "digits and hyphen",
+ "value": "user-123",
+ "problems": []
+ },
+ {
+ "name": "empty",
+ "value": "",
+ "problems": [
+ "username:required"
+ ]
+ },
+ {
+ "name": "only spaces",
+ "value": " ",
+ "problems": [
+ "username:required"
+ ]
+ },
+ {
+ "name": "number",
+ "value": 123,
+ "problems": [
+ "username:invalid-type"
+ ]
+ },
+ {
+ "name": "null",
+ "value": null,
+ "problems": [
+ "username:invalid-type"
+ ]
+ },
+ {
+ "name": "array",
+ "value": [
+ "riya"
+ ],
+ "problems": [
+ "username:invalid-type"
+ ]
+ },
+ {
+ "name": "too short",
+ "value": "ab",
+ "problems": [
+ "username:too-short"
+ ]
+ },
+ {
+ "name": "one letter that is also reserved",
+ "value": "u",
+ "problems": [
+ "username:too-short"
+ ]
+ },
+ {
+ "name": "31 characters",
+ "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "problems": [
+ "username:too-long"
+ ]
+ },
+ {
+ "name": "uppercase",
+ "value": "Riya",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "underscore",
+ "value": "riya_sharma",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "leading hyphen",
+ "value": "-riya",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "trailing hyphen",
+ "value": "riya-",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "double hyphen",
+ "value": "ri--ya",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "space inside",
+ "value": "riya sharma",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "trailing newline",
+ "value": "riya\n",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "dot",
+ "value": "riya.sharma",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "devanagari",
+ "value": "\u0930\u093f\u092f\u093e",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "Kelvin sign is not folded to k",
+ "value": "\u212ariya",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "full-width letters",
+ "value": "\uff52\uff49\uff59\uff41",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "reserved: admin",
+ "value": "admin",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: api",
+ "value": "api",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: www",
+ "value": "www",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: brand name",
+ "value": "procoderji",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: owner site",
+ "value": "satyam",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: existing route",
+ "value": "dashboard",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: hyphenated asset name",
+ "value": "apple-icon",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved and blocked",
+ "value": "login",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "blocked word with suffix",
+ "value": "paytm-help",
+ "problems": [
+ "username:blocked-word"
+ ]
+ },
+ {
+ "name": "blocked word split by hyphen",
+ "value": "pay-tm",
+ "problems": [
+ "username:blocked-word"
+ ]
+ },
+ {
+ "name": "blocked word inside",
+ "value": "my-bank-guide",
+ "problems": [
+ "username:blocked-word"
+ ]
+ },
+ {
+ "name": "blocked word with name",
+ "value": "riya-login",
+ "problems": [
+ "username:blocked-word"
+ ]
+ },
+ {
+ "name": "reserved name as part of a longer name is allowed",
+ "value": "satyam-fan",
+ "problems": []
+ }
+ ],
+ "portfolios": [
+ {
+ "name": "publish: minimal portfolio",
+ "mode": "publish",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student"
+ },
+ "problems": []
+ },
+ {
+ "name": "publish: complete portfolio",
+ "mode": "publish",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student | Web developer",
+ "about": "I build small web apps.\nI am looking for a software internship.",
+ "skills": [
+ "JavaScript",
+ "React",
+ "Node.js"
+ ],
+ "projects": [
+ {
+ "title": "Expense Tracker",
+ "link": "https://github.com/riya-example/expense-tracker",
+ "technologies": [
+ "React",
+ "Firebase"
+ ],
+ "description": "Tracks monthly spending.\nLearned state management."
+ },
+ {
+ "title": "Notes App"
+ }
+ ],
+ "education": [
+ {
+ "institution": "Example Institute of Technology",
+ "degree": "B.Tech CSE",
+ "period": "2022-2026",
+ "details": "CGPA 8.4"
+ }
+ ],
+ "experience": [
+ {
+ "organization": "Example Labs",
+ "role": "Intern",
+ "period": "Jun-Aug 2025",
+ "description": "Fixed UI bugs."
+ }
+ ],
+ "achievements": [
+ "Finalist, Example Hackathon 2025"
+ ],
+ "links": {
+ "github": "https://github.com/riya-example",
+ "linkedin": "https://www.linkedin.com/in/riya-example",
+ "website": "https://riya.example.dev",
+ "email": "riya@example.com"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "publish: empty object",
+ "mode": "publish",
+ "value": {},
+ "problems": [
+ "name:required",
+ "headline:required"
+ ]
+ },
+ {
+ "name": "draft: empty object",
+ "mode": "draft",
+ "value": {},
+ "problems": []
+ },
+ {
+ "name": "draft: blank required fields",
+ "mode": "draft",
+ "value": {
+ "name": " ",
+ "headline": ""
+ },
+ "problems": []
+ },
+ {
+ "name": "publish: blank required fields",
+ "mode": "publish",
+ "value": {
+ "name": " ",
+ "headline": "\n"
+ },
+ "problems": [
+ "name:required",
+ "headline:required"
+ ]
+ },
+ {
+ "name": "array instead of object",
+ "mode": "publish",
+ "value": [],
+ "problems": [
+ "$:invalid-type"
+ ]
+ },
+ {
+ "name": "null instead of object",
+ "mode": "publish",
+ "value": null,
+ "problems": [
+ "$:invalid-type"
+ ]
+ },
+ {
+ "name": "string instead of object",
+ "mode": "draft",
+ "value": "riya",
+ "problems": [
+ "$:invalid-type"
+ ]
+ },
+ {
+ "name": "unknown field",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "phone": "9999999999"
+ },
+ "problems": [
+ "phone:unknown-field"
+ ]
+ },
+ {
+ "name": "unknown field named __proto__",
+ "mode": "draft",
+ "value": {
+ "name": "Riya",
+ "__proto__": {
+ "admin": true
+ }
+ },
+ "problems": [
+ "__proto__:unknown-field"
+ ]
+ },
+ {
+ "name": "name has the wrong type",
+ "mode": "draft",
+ "value": {
+ "name": 5
+ },
+ "problems": [
+ "name:invalid-type"
+ ]
+ },
+ {
+ "name": "name is null",
+ "mode": "draft",
+ "value": {
+ "name": null
+ },
+ "problems": [
+ "name:invalid-type"
+ ]
+ },
+ {
+ "name": "name at the limit (80 UTF-16 units)",
+ "mode": "publish",
+ "value": {
+ "name": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00",
+ "headline": "Final-year B.Tech CSE student"
+ },
+ "problems": []
+ },
+ {
+ "name": "name over the limit (82 UTF-16 units)",
+ "mode": "publish",
+ "value": {
+ "name": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00",
+ "headline": "Final-year B.Tech CSE student"
+ },
+ "problems": [
+ "name:too-long"
+ ]
+ },
+ {
+ "name": "name with a newline",
+ "mode": "draft",
+ "value": {
+ "name": "Riya\nSharma"
+ },
+ "problems": [
+ "name:invalid-characters"
+ ]
+ },
+ {
+ "name": "headline over the limit",
+ "mode": "draft",
+ "value": {
+ "headline": "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh"
+ },
+ "problems": [
+ "headline:too-long"
+ ]
+ },
+ {
+ "name": "about at the limit",
+ "mode": "draft",
+ "value": {
+ "about": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ },
+ "problems": []
+ },
+ {
+ "name": "about over the limit",
+ "mode": "draft",
+ "value": {
+ "about": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ },
+ "problems": [
+ "about:too-long"
+ ]
+ },
+ {
+ "name": "about with a control character",
+ "mode": "draft",
+ "value": {
+ "about": "hello\u0007"
+ },
+ "problems": [
+ "about:invalid-characters"
+ ]
+ },
+ {
+ "name": "about with a right-to-left override",
+ "mode": "draft",
+ "value": {
+ "about": "hello\u202eolleh"
+ },
+ "problems": [
+ "about:invalid-characters"
+ ]
+ },
+ {
+ "name": "about with a zero-width space",
+ "mode": "draft",
+ "value": {
+ "about": "he\u200bllo"
+ },
+ "problems": [
+ "about:invalid-characters"
+ ]
+ },
+ {
+ "name": "about with an HTML tag",
+ "mode": "draft",
+ "value": {
+ "about": "Hi "
+ },
+ "problems": [
+ "about:html-like"
+ ]
+ },
+ {
+ "name": "headline with a bold tag",
+ "mode": "draft",
+ "value": {
+ "headline": "hi"
+ },
+ "problems": [
+ "headline:html-like"
+ ]
+ },
+ {
+ "name": "comparison with spaces is fine",
+ "mode": "draft",
+ "value": {
+ "about": "I like it when x < y and a <= b."
+ },
+ "problems": []
+ },
+ {
+ "name": "comparison without spaces looks like a tag",
+ "mode": "draft",
+ "value": {
+ "about": "if x"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link with credentials",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://user:pass@example.com/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link that hides the real host after @",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://github.com@evil.example/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link with a port",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://example.com:8443/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link to localhost",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://localhost/x"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link to an IPv4 address",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://192.168.0.1/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link to an IPv6 address",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://[::1]/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link with a non-ASCII host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://m\u00fcnchen.de/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link with a punycode host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://xn--mnchen-3ya.de/"
+ }
+ ]
+ },
+ "problems": []
+ },
+ {
+ "name": "link with an uppercase scheme and host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "HTTPS://Example.COM/Path"
+ }
+ ]
+ },
+ "problems": []
+ },
+ {
+ "name": "link host with a Kelvin sign is not folded to k",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://\u212aanbal.com/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link with an empty label",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://example..com/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link that is not a string",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": 42
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-type"
+ ]
+ },
+ {
+ "name": "blank link is treated as empty",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": " "
+ }
+ ]
+ },
+ "problems": []
+ },
+ {
+ "name": "education without an institution",
+ "mode": "draft",
+ "value": {
+ "education": [
+ {
+ "degree": "B.Tech"
+ }
+ ]
+ },
+ "problems": [
+ "education[0].institution:required"
+ ]
+ },
+ {
+ "name": "too many education entries",
+ "mode": "draft",
+ "value": {
+ "education": [
+ {
+ "institution": "X"
+ },
+ {
+ "institution": "X"
+ },
+ {
+ "institution": "X"
+ },
+ {
+ "institution": "X"
+ },
+ {
+ "institution": "X"
+ },
+ {
+ "institution": "X"
+ }
+ ]
+ },
+ "problems": [
+ "education:too-many"
+ ]
+ },
+ {
+ "name": "education details over the limit",
+ "mode": "draft",
+ "value": {
+ "education": [
+ {
+ "institution": "X",
+ "details": "ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
+ }
+ ]
+ },
+ "problems": [
+ "education[0].details:too-long"
+ ]
+ },
+ {
+ "name": "experience without a role",
+ "mode": "draft",
+ "value": {
+ "experience": [
+ {
+ "organization": "Acme"
+ }
+ ]
+ },
+ "problems": [
+ "experience[0].role:required"
+ ]
+ },
+ {
+ "name": "experience without an organization",
+ "mode": "draft",
+ "value": {
+ "experience": [
+ {
+ "role": "Intern"
+ }
+ ]
+ },
+ "problems": [
+ "experience[0].organization:required"
+ ]
+ },
+ {
+ "name": "too many experience entries",
+ "mode": "draft",
+ "value": {
+ "experience": [
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ }
+ ]
+ },
+ "problems": [
+ "experience:too-many"
+ ]
+ },
+ {
+ "name": "experience description over the limit",
+ "mode": "draft",
+ "value": {
+ "experience": [
+ {
+ "organization": "A",
+ "role": "B",
+ "description": "ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
+ }
+ ]
+ },
+ "problems": [
+ "experience[0].description:too-long"
+ ]
+ },
+ {
+ "name": "education entry with unknown field",
+ "mode": "draft",
+ "value": {
+ "education": [
+ {
+ "institution": "X",
+ "gpa": 9
+ }
+ ]
+ },
+ "problems": [
+ "education[0].gpa:unknown-field"
+ ]
+ },
+ {
+ "name": "achievements at the limit",
+ "mode": "draft",
+ "value": {
+ "achievements": [
+ "a0",
+ "a1",
+ "a2",
+ "a3",
+ "a4",
+ "a5",
+ "a6",
+ "a7",
+ "a8",
+ "a9"
+ ]
+ },
+ "problems": []
+ },
+ {
+ "name": "too many achievements",
+ "mode": "draft",
+ "value": {
+ "achievements": [
+ "a0",
+ "a1",
+ "a2",
+ "a3",
+ "a4",
+ "a5",
+ "a6",
+ "a7",
+ "a8",
+ "a9",
+ "a10"
+ ]
+ },
+ "problems": [
+ "achievements:too-many"
+ ]
+ },
+ {
+ "name": "achievement too long",
+ "mode": "draft",
+ "value": {
+ "achievements": [
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ ]
+ },
+ "problems": [
+ "achievements[0]:too-long"
+ ]
+ },
+ {
+ "name": "blank achievement",
+ "mode": "draft",
+ "value": {
+ "achievements": [
+ ""
+ ]
+ },
+ "problems": [
+ "achievements[0]:required"
+ ]
+ },
+ {
+ "name": "github profile",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://github.com/riya-example"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "github subdomain",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://gist.github.com/riya-example"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "github on another site",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://example.com/riya"
+ }
+ },
+ "problems": [
+ "links.github:invalid-host"
+ ]
+ },
+ {
+ "name": "github as a prefix of another host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://notgithub.com/riya"
+ }
+ },
+ "problems": [
+ "links.github:invalid-host"
+ ]
+ },
+ {
+ "name": "github as a subdomain of another host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://github.com.evil.example/riya"
+ }
+ },
+ "problems": [
+ "links.github:invalid-host"
+ ]
+ },
+ {
+ "name": "linkedin profile",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "linkedin": "https://www.linkedin.com/in/riya-example"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "linkedin regional subdomain",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "linkedin": "https://in.linkedin.com/in/riya-example"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "linkedin on another site",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "linkedin": "https://example.com/in/riya"
+ }
+ },
+ "problems": [
+ "links.linkedin:invalid-host"
+ ]
+ },
+ {
+ "name": "website on any host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "website": "https://riya.example.dev"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "website over http",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "website": "http://riya.example.dev"
+ }
+ },
+ "problems": [
+ "links.website:invalid-link"
+ ]
+ },
+ {
+ "name": "github link over 300 characters",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://github.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ }
+ },
+ "problems": [
+ "links.github:too-long"
+ ]
+ },
+ {
+ "name": "links with an unknown key",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "twitter": "https://x.com/riya"
+ }
+ },
+ "problems": [
+ "links.twitter:unknown-field"
+ ]
+ },
+ {
+ "name": "links is not an object",
+ "mode": "draft",
+ "value": {
+ "links": [
+ "https://github.com/riya"
+ ]
+ },
+ "problems": [
+ "links:invalid-type"
+ ]
+ },
+ {
+ "name": "blank link entries are treated as empty",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "",
+ "linkedin": " ",
+ "website": "",
+ "email": ""
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "valid email",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "riya.sharma+jobs@example.co.in"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "email without a domain",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "riya@"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email without a top-level domain",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "riya@example"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email with a mailto prefix",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "mailto:riya@example.com"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email with a space",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "riya @example.com"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email with a trailing newline",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "riya@example.com\n"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email with two at signs",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "a@b@example.com"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email over 254 characters",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "a@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.com"
+ }
+ },
+ "problems": [
+ "links.email:too-long"
+ ]
+ },
+ {
+ "name": "email local part over 64 characters",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@example.com"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "several problems are all reported",
+ "mode": "publish",
+ "value": {
+ "name": "",
+ "headline": "x",
+ "skills": [
+ "",
+ "ok"
+ ],
+ "projects": [
+ {
+ "link": "http://x.example"
+ }
+ ],
+ "links": {
+ "github": "https://example.com"
+ }
+ },
+ "problems": [
+ "name:required",
+ "headline:html-like",
+ "skills[0]:required",
+ "projects[0].title:required",
+ "projects[0].link:invalid-link",
+ "links.github:invalid-host"
+ ]
+ }
+ ]
+}
diff --git a/shared/portfolio/rules.json b/shared/portfolio/rules.json
new file mode 100644
index 0000000..2b79153
--- /dev/null
+++ b/shared/portfolio/rules.json
@@ -0,0 +1,67 @@
+{
+ "version": 1,
+ "requiredForPublish": ["name", "headline"],
+ "maxItems": {
+ "skills": 30,
+ "projects": 8,
+ "projects.technologies": 12,
+ "education": 5,
+ "experience": 6,
+ "achievements": 10
+ },
+ "maxLength": {
+ "name": 80,
+ "headline": 120,
+ "about": 1000,
+ "skills.item": 40,
+ "projects.title": 80,
+ "projects.description": 500,
+ "projects.technologies.item": 40,
+ "education.institution": 120,
+ "education.degree": 120,
+ "education.period": 40,
+ "education.details": 300,
+ "experience.organization": 120,
+ "experience.role": 120,
+ "experience.period": 40,
+ "experience.description": 500,
+ "achievements.item": 200,
+ "link": 300,
+ "email": 254
+ },
+ "multilineFields": [
+ "about",
+ "projects.description",
+ "education.details",
+ "experience.description"
+ ],
+ "text": {
+ "controlCharacters": "[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069\\ufeff]",
+ "htmlLike": "<[A-Za-z/!?]"
+ },
+ "link": {
+ "scheme": "https://",
+ "forbiddenCharacters": "[\\u0000-\\u0020\\u0022\\u003c\\u003e\\u005c\\u0060\\u007f-\\u009f\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069\\ufeff]",
+ "hostPattern": "^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+(?:[a-z]{2,}|xn--[a-z0-9]+(?:-[a-z0-9]+)*)$",
+ "hostRestrictions": {
+ "links.github": ["github.com"],
+ "links.linkedin": ["linkedin.com"]
+ }
+ },
+ "emailPattern": "^[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\\.)+[A-Za-z]{2,}$",
+ "username": {
+ "minLength": 3,
+ "maxLength": 30,
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
+ "reserved": [
+ "u", "www", "api", "app", "admin", "administrator", "root", "mail", "email", "smtp", "ftp",
+ "docs", "blog", "login", "logout", "signin", "signup", "register", "auth", "account",
+ "support", "help", "contact", "about", "status", "static", "assets", "cdn", "dev", "test",
+ "staging", "preview", "dashboard", "courses", "learn", "practice", "games", "guides", "jobs",
+ "privacy", "terms", "disclaimer", "copyright", "portfolio", "portfolios", "procoderji",
+ "satyam", "robots", "sitemap", "favicon", "icon", "apple-icon", "logo", "brand", "og",
+ "new", "edit", "settings", "profile", "me", "null", "undefined"
+ ],
+ "blockedWords": ["paytm", "phonepe", "razorpay", "bank", "login", "verify", "password", "otp", "wallet"]
+ }
+}
From c8d6182afbeb98e25d8990d88bd4f77d4066de95 Mon Sep 17 00:00:00 2001
From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com>
Date: Tue, 10 Nov 2026 15:15:37 +0530
Subject: [PATCH 2/4] feat(portfolio): add the TypeScript validator
Validate untrusted portfolio data and usernames against the shared
rules, reporting each problem as a path and a stable code. Drafts only
need to be well-formed; publishing also requires a name and headline.
Links are checked without a URL parser, and only A-Z is lower-cased,
so the result matches the .NET validator exactly. Run all shared
fixtures in the unit tests.
Refs #42
---
src/features/portfolio/portfolio.test.ts | 107 ++++++++++
src/features/portfolio/rules.ts | 30 +++
src/features/portfolio/types.ts | 65 ++++++
src/features/portfolio/validation.ts | 249 +++++++++++++++++++++++
4 files changed, 451 insertions(+)
create mode 100644 src/features/portfolio/portfolio.test.ts
create mode 100644 src/features/portfolio/rules.ts
create mode 100644 src/features/portfolio/types.ts
create mode 100644 src/features/portfolio/validation.ts
diff --git a/src/features/portfolio/portfolio.test.ts b/src/features/portfolio/portfolio.test.ts
new file mode 100644
index 0000000..8cb00d5
--- /dev/null
+++ b/src/features/portfolio/portfolio.test.ts
@@ -0,0 +1,107 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import fixtures from "../../../shared/portfolio/fixtures.json" with { type: "json" };
+import { PORTFOLIO_RULES } from "./rules.ts";
+import {
+ problemKey,
+ validatePortfolio,
+ validateUsername,
+} from "./validation.ts";
+import type { PortfolioMode } from "./types.ts";
+
+// The backend runs the same fixtures (PortfolioValidatorTests). Add a case there first when
+// a rule changes, so both runtimes keep agreeing.
+
+const keys = (problems: Parameters[0][]) =>
+ problems.map(problemKey).sort();
+
+test("every shared username fixture gives the expected problems", () => {
+ for (const fixture of fixtures.usernames)
+ assert.deepEqual(
+ keys(validateUsername(fixture.value)),
+ [...fixture.problems].sort(),
+ fixture.name,
+ );
+});
+
+test("every shared portfolio fixture gives the expected problems", () => {
+ for (const fixture of fixtures.portfolios)
+ assert.deepEqual(
+ keys(validatePortfolio(fixture.value, fixture.mode as PortfolioMode)),
+ [...fixture.problems].sort(),
+ fixture.name,
+ );
+});
+
+test("the fixtures cover valid and invalid input and both modes", () => {
+ assert.ok(fixtures.usernames.some((f) => f.problems.length === 0));
+ assert.ok(fixtures.usernames.some((f) => f.problems.length > 0));
+ assert.ok(fixtures.portfolios.some((f) => f.mode === "draft"));
+ assert.ok(fixtures.portfolios.some((f) => f.mode === "publish"));
+ assert.ok(fixtures.portfolios.some((f) => f.problems.length === 0));
+});
+
+test("every problem code the validator can report appears in a fixture", () => {
+ const seen = new Set(
+ [...fixtures.usernames, ...fixtures.portfolios].flatMap((f) =>
+ f.problems.map((p) => p.split(":")[1]),
+ ),
+ );
+ for (const code of [
+ "required",
+ "invalid-type",
+ "unknown-field",
+ "too-short",
+ "too-long",
+ "too-many",
+ "invalid-characters",
+ "html-like",
+ "invalid-format",
+ "invalid-link",
+ "invalid-host",
+ "invalid-email",
+ "reserved",
+ "blocked-word",
+ ])
+ assert.ok(seen.has(code), `no fixture produces ${code}`);
+});
+
+test("reserved names cover every public route and the names listed in ADR 0002", () => {
+ const reserved = new Set(PORTFOLIO_RULES.username.reserved);
+ for (const name of [
+ "www", "api", "app", "admin", "mail", "docs", "blog", "login", "support",
+ "help", "procoderji", "satyam", "u", "learn", "practice", "account",
+ "dashboard", "games", "guides", "jobs", "about", "privacy", "terms",
+ "disclaimer", "copyright", "courses",
+ ])
+ assert.ok(reserved.has(name), name);
+});
+
+test("reserved names and blocked words are well-formed usernames in lowercase", () => {
+ const pattern = new RegExp(PORTFOLIO_RULES.username.pattern);
+ for (const name of PORTFOLIO_RULES.username.reserved)
+ assert.ok(pattern.test(name), `reserved name ${name} can never be claimed anyway`);
+ for (const word of PORTFOLIO_RULES.username.blockedWords)
+ assert.ok(/^[a-z0-9]+$/.test(word), `blocked word ${word} must be lowercase letters and digits`);
+});
+
+test("every limit in the rules is a positive whole number", () => {
+ for (const [key, max] of Object.entries(PORTFOLIO_RULES.maxLength))
+ assert.ok(Number.isInteger(max) && max > 0, key);
+ for (const [key, max] of Object.entries(PORTFOLIO_RULES.maxItems))
+ assert.ok(Number.isInteger(max) && max > 0, key);
+});
+
+test("validating does not modify the input", () => {
+ const input = { name: "Riya", skills: ["a"], links: { github: "https://github.com/riya" } };
+ const copy = structuredClone(input);
+ validatePortfolio(input, "publish");
+ assert.deepEqual(input, copy);
+});
+
+test("a huge value of the wrong type is rejected quickly", () => {
+ const started = Date.now();
+ assert.deepEqual(keys(validatePortfolio({ name: "x".repeat(2_000_000) }, "draft")), ["name:too-long"]);
+ assert.deepEqual(keys(validatePortfolio({ links: { github: "https://" + "a".repeat(2_000_000) } }, "draft")), ["links.github:too-long"]);
+ assert.ok(Date.now() - started < 1_000);
+});
diff --git a/src/features/portfolio/rules.ts b/src/features/portfolio/rules.ts
new file mode 100644
index 0000000..e6e5db4
--- /dev/null
+++ b/src/features/portfolio/rules.ts
@@ -0,0 +1,30 @@
+import rulesData from "../../../shared/portfolio/rules.json" with { type: "json" };
+
+/**
+ * The portfolio rules shared with the backend. Numbers, reserved names and patterns live in
+ * `shared/portfolio/rules.json`; the backend reads the same file.
+ */
+export interface PortfolioRules {
+ version: number;
+ requiredForPublish: readonly string[];
+ maxItems: Readonly>;
+ maxLength: Readonly>;
+ multilineFields: readonly string[];
+ text: { controlCharacters: string; htmlLike: string };
+ link: {
+ scheme: string;
+ forbiddenCharacters: string;
+ hostPattern: string;
+ hostRestrictions: Readonly>;
+ };
+ emailPattern: string;
+ username: {
+ minLength: number;
+ maxLength: number;
+ pattern: string;
+ reserved: readonly string[];
+ blockedWords: readonly string[];
+ };
+}
+
+export const PORTFOLIO_RULES: PortfolioRules = rulesData;
diff --git a/src/features/portfolio/types.ts b/src/features/portfolio/types.ts
new file mode 100644
index 0000000..8b80a3d
--- /dev/null
+++ b/src/features/portfolio/types.ts
@@ -0,0 +1,65 @@
+export type PortfolioMode = "draft" | "publish";
+
+export type PortfolioProblemCode =
+ | "required"
+ | "invalid-type"
+ | "unknown-field"
+ | "too-short"
+ | "too-long"
+ | "too-many"
+ | "invalid-characters"
+ | "html-like"
+ | "invalid-format"
+ | "invalid-link"
+ | "invalid-host"
+ | "invalid-email"
+ | "reserved"
+ | "blocked-word";
+
+/** A validation problem at a field path such as `projects[0].link`. */
+export interface PortfolioProblem {
+ path: string;
+ code: PortfolioProblemCode;
+}
+
+export interface PortfolioProject {
+ title: string;
+ link?: string;
+ technologies?: string[];
+ description?: string;
+}
+
+export interface PortfolioEducation {
+ institution: string;
+ degree?: string;
+ period?: string;
+ details?: string;
+}
+
+export interface PortfolioExperience {
+ organization: string;
+ role: string;
+ period?: string;
+ description?: string;
+}
+
+export interface PortfolioLinks {
+ github?: string;
+ linkedin?: string;
+ website?: string;
+ /** An email address. The page builds the `mailto:` link; students never store one. */
+ email?: string;
+}
+
+/** A valid portfolio. A draft may omit `name` and `headline`; a published one may not. */
+export interface PortfolioData {
+ name?: string;
+ headline?: string;
+ about?: string;
+ skills?: string[];
+ projects?: PortfolioProject[];
+ education?: PortfolioEducation[];
+ experience?: PortfolioExperience[];
+ achievements?: string[];
+ links?: PortfolioLinks;
+}
diff --git a/src/features/portfolio/validation.ts b/src/features/portfolio/validation.ts
new file mode 100644
index 0000000..7ee620d
--- /dev/null
+++ b/src/features/portfolio/validation.ts
@@ -0,0 +1,249 @@
+import { PORTFOLIO_RULES as RULES } from "./rules.ts";
+import type {
+ PortfolioMode,
+ PortfolioProblem,
+ PortfolioProblemCode,
+} from "./types.ts";
+
+/*
+ * Portfolio validation. The .NET validator in
+ * backend/src/ProCoderJi.Api/Features/Portfolio/ implements the same rules, and both run
+ * against shared/portfolio/fixtures.json. Keep the two in step: when a check changes, add a
+ * fixture first. The rules avoid URL parsers and locale-aware string functions on purpose,
+ * because those behave differently in JavaScript and .NET.
+ */
+
+const CONTROL = new RegExp(RULES.text.controlCharacters);
+const HTML_LIKE = new RegExp(RULES.text.htmlLike);
+const LINK_FORBIDDEN = new RegExp(RULES.link.forbiddenCharacters);
+const HOST = new RegExp(RULES.link.hostPattern);
+const EMAIL = new RegExp(RULES.emailPattern);
+const USERNAME = new RegExp(RULES.username.pattern);
+const RESERVED = new Set(RULES.username.reserved);
+const MULTILINE = new Set(RULES.multilineFields);
+const BLANK = /^[ \t\r\n]*$/;
+const LINE_BREAK = /[\r\n]/;
+const AUTHORITY_END = /[/?#]/;
+
+type Problems = PortfolioProblem[];
+type Value = unknown;
+type ItemCheck = (path: string, item: Value) => void;
+
+function add(problems: Problems, path: string, code: PortfolioProblemCode) {
+ problems.push({ path, code });
+}
+
+function limit(table: Readonly>, key: string): number {
+ const value = table[key];
+ if (value === undefined) throw new Error(`Missing portfolio rule: ${key}`);
+ return value;
+}
+
+/** Lower-cases A-Z only, so other characters such as the Kelvin sign are never folded. */
+function asciiLower(text: string): string {
+ return text.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
+}
+
+function isObject(value: Value): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function get(object: Record, key: string): Value {
+ return Object.hasOwn(object, key) ? object[key] : undefined;
+}
+
+function checkUnknownFields(
+ problems: Problems,
+ path: string,
+ object: Record,
+ allowed: readonly string[],
+) {
+ for (const key of Object.keys(object))
+ if (!allowed.includes(key))
+ add(problems, path ? `${path}.${key}` : key, "unknown-field");
+}
+
+function checkText(
+ problems: Problems,
+ path: string,
+ value: Value,
+ key: string,
+ required: boolean,
+) {
+ if (value === undefined) {
+ if (required) add(problems, path, "required");
+ return;
+ }
+ if (typeof value !== "string") return add(problems, path, "invalid-type");
+ if (BLANK.test(value)) {
+ if (required) add(problems, path, "required");
+ return;
+ }
+ if (value.length > limit(RULES.maxLength, key)) return add(problems, path, "too-long");
+ if (CONTROL.test(value) || (!MULTILINE.has(key) && LINE_BREAK.test(value)))
+ return add(problems, path, "invalid-characters");
+ if (HTML_LIKE.test(value)) add(problems, path, "html-like");
+}
+
+/** An optional https link. `hostKey` selects the host restriction, such as `links.github`. */
+function checkLink(
+ problems: Problems,
+ path: string,
+ value: Value,
+ hostKey?: string,
+) {
+ if (value === undefined) return;
+ if (typeof value !== "string") return add(problems, path, "invalid-type");
+ if (BLANK.test(value)) return;
+ if (value.length > limit(RULES.maxLength, "link")) return add(problems, path, "too-long");
+ const scheme = RULES.link.scheme;
+ if (
+ LINK_FORBIDDEN.test(value) ||
+ asciiLower(value.slice(0, scheme.length)) !== scheme
+ )
+ return add(problems, path, "invalid-link");
+ const rest = value.slice(scheme.length);
+ const end = rest.search(AUTHORITY_END);
+ const authority = end === -1 ? rest : rest.slice(0, end);
+ // The host pattern allows only letters, digits, hyphens and dots, so credentials
+ // (`user@host`), ports (`host:8443`), IP literals and empty hosts all fail it.
+ const host = asciiLower(authority);
+ if (!HOST.test(host)) return add(problems, path, "invalid-link");
+ const allowed = hostKey ? RULES.link.hostRestrictions[hostKey] : undefined;
+ if (allowed && !allowed.some((domain) => host === domain || host.endsWith(`.${domain}`)))
+ add(problems, path, "invalid-host");
+}
+
+function checkEmail(problems: Problems, path: string, value: Value) {
+ if (value === undefined) return;
+ if (typeof value !== "string") return add(problems, path, "invalid-type");
+ if (BLANK.test(value)) return;
+ if (value.length > limit(RULES.maxLength, "email")) return add(problems, path, "too-long");
+ if (!EMAIL.test(value)) add(problems, path, "invalid-email");
+}
+
+function checkList(
+ problems: Problems,
+ path: string,
+ value: Value,
+ key: string,
+ checkItem: ItemCheck,
+) {
+ if (value === undefined) return;
+ if (!Array.isArray(value)) return add(problems, path, "invalid-type");
+ if (value.length > limit(RULES.maxItems, key)) return add(problems, path, "too-many");
+ value.forEach((item, index) => checkItem(`${path}[${index}]`, item));
+}
+
+function checkObjectItem(
+ problems: Problems,
+ path: string,
+ item: Value,
+ allowed: readonly string[],
+ checkFields: (object: Record) => void,
+) {
+ if (!isObject(item)) return add(problems, path, "invalid-type");
+ checkUnknownFields(problems, path, item, allowed);
+ checkFields(item);
+}
+
+/** Checks a username for the public address `/u/`. */
+export function validateUsername(value: unknown): PortfolioProblem[] {
+ const problems: Problems = [];
+ const path = "username";
+ const rules = RULES.username;
+ if (typeof value !== "string") return [{ path, code: "invalid-type" }];
+ if (BLANK.test(value)) return [{ path, code: "required" }];
+ if (value.length < rules.minLength) return [{ path, code: "too-short" }];
+ if (value.length > rules.maxLength) return [{ path, code: "too-long" }];
+ if (!USERNAME.test(value)) return [{ path, code: "invalid-format" }];
+ if (RESERVED.has(value)) return [{ path, code: "reserved" }];
+ const squashed = value.replaceAll("-", "");
+ if (rules.blockedWords.some((word) => squashed.includes(word)))
+ add(problems, path, "blocked-word");
+ return problems;
+}
+
+/**
+ * Checks untrusted portfolio data. A draft only has to be well-formed; publishing also needs
+ * the fields listed in `requiredForPublish`. An empty result means the data is valid.
+ */
+export function validatePortfolio(
+ value: unknown,
+ mode: PortfolioMode,
+): PortfolioProblem[] {
+ const problems: Problems = [];
+ if (!isObject(value)) return [{ path: "$", code: "invalid-type" }];
+ checkUnknownFields(problems, "", value, [
+ "name",
+ "headline",
+ "about",
+ "skills",
+ "projects",
+ "education",
+ "experience",
+ "achievements",
+ "links",
+ ]);
+
+ const requiredOnPublish = (key: string) =>
+ mode === "publish" && RULES.requiredForPublish.includes(key);
+ checkText(problems, "name", get(value, "name"), "name", requiredOnPublish("name"));
+ checkText(problems, "headline", get(value, "headline"), "headline", requiredOnPublish("headline"));
+ checkText(problems, "about", get(value, "about"), "about", false);
+
+ checkList(problems, "skills", get(value, "skills"), "skills", (path, item) =>
+ checkText(problems, path, item, "skills.item", true),
+ );
+
+ checkList(problems, "projects", get(value, "projects"), "projects", (path, item) =>
+ checkObjectItem(problems, path, item, ["title", "link", "technologies", "description"], (project) => {
+ checkText(problems, `${path}.title`, get(project, "title"), "projects.title", true);
+ checkLink(problems, `${path}.link`, get(project, "link"));
+ checkList(problems, `${path}.technologies`, get(project, "technologies"), "projects.technologies", (techPath, tech) =>
+ checkText(problems, techPath, tech, "projects.technologies.item", true),
+ );
+ checkText(problems, `${path}.description`, get(project, "description"), "projects.description", false);
+ }),
+ );
+
+ checkList(problems, "education", get(value, "education"), "education", (path, item) =>
+ checkObjectItem(problems, path, item, ["institution", "degree", "period", "details"], (entry) => {
+ checkText(problems, `${path}.institution`, get(entry, "institution"), "education.institution", true);
+ checkText(problems, `${path}.degree`, get(entry, "degree"), "education.degree", false);
+ checkText(problems, `${path}.period`, get(entry, "period"), "education.period", false);
+ checkText(problems, `${path}.details`, get(entry, "details"), "education.details", false);
+ }),
+ );
+
+ checkList(problems, "experience", get(value, "experience"), "experience", (path, item) =>
+ checkObjectItem(problems, path, item, ["organization", "role", "period", "description"], (entry) => {
+ checkText(problems, `${path}.organization`, get(entry, "organization"), "experience.organization", true);
+ checkText(problems, `${path}.role`, get(entry, "role"), "experience.role", true);
+ checkText(problems, `${path}.period`, get(entry, "period"), "experience.period", false);
+ checkText(problems, `${path}.description`, get(entry, "description"), "experience.description", false);
+ }),
+ );
+
+ checkList(problems, "achievements", get(value, "achievements"), "achievements", (path, item) =>
+ checkText(problems, path, item, "achievements.item", true),
+ );
+
+ const links = get(value, "links");
+ if (links !== undefined) {
+ if (!isObject(links)) add(problems, "links", "invalid-type");
+ else {
+ checkUnknownFields(problems, "links", links, ["github", "linkedin", "website", "email"]);
+ checkLink(problems, "links.github", get(links, "github"), "links.github");
+ checkLink(problems, "links.linkedin", get(links, "linkedin"), "links.linkedin");
+ checkLink(problems, "links.website", get(links, "website"));
+ checkEmail(problems, "links.email", get(links, "email"));
+ }
+ }
+ return problems;
+}
+
+/** The stable `path:code` form used by the shared fixtures and for messages. */
+export function problemKey(problem: PortfolioProblem): string {
+ return `${problem.path}:${problem.code}`;
+}
From e6aa8ada640d041c0b04c54e37218be820d09895 Mon Sep 17 00:00:00 2001
From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com>
Date: Tue, 10 Nov 2026 15:15:38 +0530
Subject: [PATCH 3/4] feat(portfolio): add the .NET validator
Implement the same portfolio and username rules in the backend, read
from the shared rules file copied into the build output, and run the
shared fixtures in the test project. Anchor patterns with \z because
.NET's $ also matches before a trailing newline, and fail loudly when
a rule key is missing instead of skipping the check.
Refs #42
---
.../Features/Portfolio/PortfolioRules.cs | 79 ++++++
.../Features/Portfolio/PortfolioValidator.cs | 246 ++++++++++++++++++
.../src/ProCoderJi.Api/ProCoderJi.Api.csproj | 1 +
.../PortfolioValidatorTests.cs | 107 ++++++++
.../ProCoderJi.Api.Tests.csproj | 4 +
5 files changed, 437 insertions(+)
create mode 100644 backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs
create mode 100644 backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs
create mode 100644 backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs
diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs
new file mode 100644
index 0000000..7cee61e
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs
@@ -0,0 +1,79 @@
+using System.Text.Json;
+
+namespace ProCoderJi.Api.Features.Portfolio;
+
+///
+/// The portfolio rules shared with the frontend, read from shared/portfolio/rules.json
+/// (copied to Data/portfolio-rules.json at build time).
+///
+public sealed class PortfolioRules
+{
+ private static readonly JsonSerializerOptions RulesJson = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow
+ };
+
+ public required IReadOnlyList RequiredForPublish { get; init; }
+ public required IReadOnlyDictionary MaxItems { get; init; }
+ public required IReadOnlyDictionary MaxLength { get; init; }
+ public required IReadOnlyList MultilineFields { get; init; }
+ public required TextRules Text { get; init; }
+ public required LinkRules Link { get; init; }
+ public required string EmailPattern { get; init; }
+ public required UsernameRules Username { get; init; }
+
+ public sealed record TextRules(string ControlCharacters, string HtmlLike);
+
+ public sealed record LinkRules(
+ string Scheme,
+ string ForbiddenCharacters,
+ string HostPattern,
+ IReadOnlyDictionary HostRestrictions);
+
+ public sealed record UsernameRules(
+ int MinLength,
+ int MaxLength,
+ string Pattern,
+ IReadOnlyList Reserved,
+ IReadOnlyList BlockedWords);
+
+ private sealed record RulesDocument(
+ int Version,
+ List? RequiredForPublish,
+ Dictionary? MaxItems,
+ Dictionary? MaxLength,
+ List? MultilineFields,
+ TextRules? Text,
+ LinkRules? Link,
+ string? EmailPattern,
+ UsernameRules? Username);
+
+ public static PortfolioRules FromJson(string json)
+ {
+ RulesDocument? document;
+ try { document = JsonSerializer.Deserialize(json, RulesJson); }
+ catch (JsonException cause) { throw new InvalidDataException("The shared portfolio rules are not valid JSON.", cause); }
+ if (document is not { Version: 1 } || document.RequiredForPublish is null || document.MaxItems is null
+ || document.MaxLength is null || document.MultilineFields is null || document.Text is null
+ || document.Link is null || document.EmailPattern is null || document.Username is null
+ || document.Username.Reserved is null || document.Username.BlockedWords is null
+ || document.Link.HostRestrictions is null)
+ throw new InvalidDataException("The shared portfolio rules are incomplete or use an unsupported version.");
+ return new PortfolioRules
+ {
+ RequiredForPublish = document.RequiredForPublish,
+ MaxItems = document.MaxItems,
+ MaxLength = document.MaxLength,
+ MultilineFields = document.MultilineFields,
+ Text = document.Text,
+ Link = document.Link,
+ EmailPattern = document.EmailPattern,
+ Username = document.Username
+ };
+ }
+
+ /// Loads the rules copied next to the application.
+ public static PortfolioRules LoadDefault() =>
+ FromJson(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data", "portfolio-rules.json")));
+}
diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs
new file mode 100644
index 0000000..31f3005
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs
@@ -0,0 +1,246 @@
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace ProCoderJi.Api.Features.Portfolio;
+
+public enum PortfolioMode { Draft, Publish }
+
+/// A problem at a field path such as projects[0].link.
+public readonly record struct PortfolioProblem(string Path, string Code)
+{
+ /// The stable path:code form used by the shared fixtures.
+ public override string ToString() => $"{Path}:{Code}";
+}
+
+///
+/// Trusted portfolio validation. The frontend implements the same rules in
+/// src/features/portfolio/validation.ts, and both run against
+/// shared/portfolio/fixtures.json. Keep the two in step: when a check changes, add a
+/// fixture first. The rules avoid URI parsers and culture-aware string functions on purpose,
+/// because those behave differently in .NET and JavaScript.
+///
+public sealed class PortfolioValidator
+{
+ private static readonly string[] TopLevelFields =
+ ["name", "headline", "about", "skills", "projects", "education", "experience", "achievements", "links"];
+
+ private readonly PortfolioRules rules;
+ private readonly Regex control;
+ private readonly Regex htmlLike;
+ private readonly Regex linkForbidden;
+ private readonly Regex host;
+ private readonly Regex email;
+ private readonly Regex username;
+ private readonly HashSet reserved;
+ private readonly HashSet multiline;
+
+ public PortfolioValidator(PortfolioRules rules)
+ {
+ this.rules = rules;
+ control = Pattern(rules.Text.ControlCharacters);
+ htmlLike = Pattern(rules.Text.HtmlLike);
+ linkForbidden = Pattern(rules.Link.ForbiddenCharacters);
+ host = Anchored(rules.Link.HostPattern);
+ email = Anchored(rules.EmailPattern);
+ username = Anchored(rules.Username.Pattern);
+ reserved = new HashSet(rules.Username.Reserved, StringComparer.Ordinal);
+ multiline = new HashSet(rules.MultilineFields, StringComparer.Ordinal);
+ }
+
+ public static PortfolioValidator CreateDefault() => new(PortfolioRules.LoadDefault());
+
+ // .NET's "$" also matches before a final newline; "\z" does not, so "riya\n" cannot slip through.
+ private static Regex Anchored(string pattern) => new(pattern + "\\z", RegexOptions.CultureInvariant);
+ private static Regex Pattern(string pattern) => new(pattern, RegexOptions.CultureInvariant);
+
+ public IReadOnlyList ValidateUsername(string value) =>
+ ValidateUsername(JsonValue.Create(value));
+
+ /// Checks a username for the public address /u/<username>.
+ public IReadOnlyList ValidateUsername(JsonNode? node)
+ {
+ const string path = "username";
+ var rule = rules.Username;
+ if (!TryString(node, out var value)) return [new(path, "invalid-type")];
+ if (IsBlank(value)) return [new(path, "required")];
+ if (value.Length < rule.MinLength) return [new(path, "too-short")];
+ if (value.Length > rule.MaxLength) return [new(path, "too-long")];
+ if (!username.IsMatch(value)) return [new(path, "invalid-format")];
+ if (reserved.Contains(value)) return [new(path, "reserved")];
+ var squashed = value.Replace("-", "", StringComparison.Ordinal);
+ if (rule.BlockedWords.Any(word => squashed.Contains(word, StringComparison.Ordinal)))
+ return [new(path, "blocked-word")];
+ return [];
+ }
+
+ ///
+ /// Checks untrusted portfolio data. A draft only has to be well-formed; publishing also needs
+ /// the fields listed in requiredForPublish. An empty result means the data is valid.
+ ///
+ public IReadOnlyList Validate(JsonNode? node, PortfolioMode mode)
+ {
+ var problems = new List();
+ if (node is not JsonObject root) return [new("$", "invalid-type")];
+ CheckUnknownFields(problems, "", root, TopLevelFields);
+
+ bool RequiredOnPublish(string key) => mode == PortfolioMode.Publish && rules.RequiredForPublish.Contains(key);
+ CheckText(problems, "name", Get(root, "name"), "name", RequiredOnPublish("name"));
+ CheckText(problems, "headline", Get(root, "headline"), "headline", RequiredOnPublish("headline"));
+ CheckText(problems, "about", Get(root, "about"), "about", false);
+
+ CheckList(problems, "skills", Get(root, "skills"), "skills", (path, item) =>
+ CheckText(problems, path, new(true, item), "skills.item", true));
+
+ CheckList(problems, "projects", Get(root, "projects"), "projects", (path, item) =>
+ CheckObjectItem(problems, path, item, ["title", "link", "technologies", "description"], project =>
+ {
+ CheckText(problems, $"{path}.title", Get(project, "title"), "projects.title", true);
+ CheckLink(problems, $"{path}.link", Get(project, "link"), null);
+ CheckList(problems, $"{path}.technologies", Get(project, "technologies"), "projects.technologies", (techPath, tech) =>
+ CheckText(problems, techPath, new(true, tech), "projects.technologies.item", true));
+ CheckText(problems, $"{path}.description", Get(project, "description"), "projects.description", false);
+ }));
+
+ CheckList(problems, "education", Get(root, "education"), "education", (path, item) =>
+ CheckObjectItem(problems, path, item, ["institution", "degree", "period", "details"], entry =>
+ {
+ CheckText(problems, $"{path}.institution", Get(entry, "institution"), "education.institution", true);
+ CheckText(problems, $"{path}.degree", Get(entry, "degree"), "education.degree", false);
+ CheckText(problems, $"{path}.period", Get(entry, "period"), "education.period", false);
+ CheckText(problems, $"{path}.details", Get(entry, "details"), "education.details", false);
+ }));
+
+ CheckList(problems, "experience", Get(root, "experience"), "experience", (path, item) =>
+ CheckObjectItem(problems, path, item, ["organization", "role", "period", "description"], entry =>
+ {
+ CheckText(problems, $"{path}.organization", Get(entry, "organization"), "experience.organization", true);
+ CheckText(problems, $"{path}.role", Get(entry, "role"), "experience.role", true);
+ CheckText(problems, $"{path}.period", Get(entry, "period"), "experience.period", false);
+ CheckText(problems, $"{path}.description", Get(entry, "description"), "experience.description", false);
+ }));
+
+ CheckList(problems, "achievements", Get(root, "achievements"), "achievements", (path, item) =>
+ CheckText(problems, path, new(true, item), "achievements.item", true));
+
+ var links = Get(root, "links");
+ if (links.Present)
+ {
+ if (links.Node is not JsonObject linkObject) problems.Add(new("links", "invalid-type"));
+ else
+ {
+ CheckUnknownFields(problems, "links", linkObject, ["github", "linkedin", "website", "email"]);
+ CheckLink(problems, "links.github", Get(linkObject, "github"), "links.github");
+ CheckLink(problems, "links.linkedin", Get(linkObject, "linkedin"), "links.linkedin");
+ CheckLink(problems, "links.website", Get(linkObject, "website"), null);
+ CheckEmail(problems, "links.email", Get(linkObject, "email"));
+ }
+ }
+ return problems;
+ }
+
+ private readonly record struct Field(bool Present, JsonNode? Node);
+
+ private static Field Get(JsonObject source, string key) =>
+ source.TryGetPropertyValue(key, out var node) ? new(true, node) : new(false, null);
+
+ private static bool TryString(JsonNode? node, out string value)
+ {
+ if (node is JsonValue json && json.TryGetValue(out var text) && text is not null)
+ {
+ value = text;
+ return true;
+ }
+ value = "";
+ return false;
+ }
+
+ private static bool IsBlank(string value) => value.All(c => c is ' ' or '\t' or '\r' or '\n');
+
+ /// Lower-cases A-Z only, so other characters such as the Kelvin sign are never folded.
+ private static string AsciiLower(string value) =>
+ string.Create(value.Length, value, static (span, source) =>
+ {
+ for (var i = 0; i < source.Length; i++)
+ span[i] = source[i] is >= 'A' and <= 'Z' ? (char)(source[i] + 32) : source[i];
+ });
+
+ private int Limit(IReadOnlyDictionary table, string key) =>
+ table.TryGetValue(key, out var value) ? value : throw new InvalidDataException($"Missing portfolio rule: {key}");
+
+ private static void CheckUnknownFields(List problems, string path, JsonObject source, IReadOnlyCollection allowed)
+ {
+ foreach (var (key, _) in source)
+ if (!allowed.Contains(key)) problems.Add(new(path.Length == 0 ? key : $"{path}.{key}", "unknown-field"));
+ }
+
+ private void CheckText(List problems, string path, Field field, string key, bool required)
+ {
+ if (!field.Present)
+ {
+ if (required) problems.Add(new(path, "required"));
+ return;
+ }
+ if (!TryString(field.Node, out var value)) { problems.Add(new(path, "invalid-type")); return; }
+ if (IsBlank(value))
+ {
+ if (required) problems.Add(new(path, "required"));
+ return;
+ }
+ if (value.Length > Limit(rules.MaxLength, key)) { problems.Add(new(path, "too-long")); return; }
+ if (control.IsMatch(value) || (!multiline.Contains(key) && value.AsSpan().IndexOfAny('\r', '\n') >= 0))
+ {
+ problems.Add(new(path, "invalid-characters"));
+ return;
+ }
+ if (htmlLike.IsMatch(value)) problems.Add(new(path, "html-like"));
+ }
+
+ /// An optional https link. selects the host restriction, such as links.github.
+ private void CheckLink(List problems, string path, Field field, string? hostKey)
+ {
+ if (!field.Present) return;
+ if (!TryString(field.Node, out var value)) { problems.Add(new(path, "invalid-type")); return; }
+ if (IsBlank(value)) return;
+ if (value.Length > Limit(rules.MaxLength, "link")) { problems.Add(new(path, "too-long")); return; }
+ var scheme = rules.Link.Scheme;
+ if (linkForbidden.IsMatch(value) || value.Length < scheme.Length || AsciiLower(value[..scheme.Length]) != scheme)
+ {
+ problems.Add(new(path, "invalid-link"));
+ return;
+ }
+ var rest = value[scheme.Length..];
+ var end = rest.AsSpan().IndexOfAny('/', '?', '#');
+ var authority = end < 0 ? rest : rest[..end];
+ // The host pattern allows only letters, digits, hyphens and dots, so credentials
+ // (user@host), ports (host:8443), IP literals and empty hosts all fail it.
+ var hostName = AsciiLower(authority);
+ if (!host.IsMatch(hostName)) { problems.Add(new(path, "invalid-link")); return; }
+ if (hostKey is not null && rules.Link.HostRestrictions.TryGetValue(hostKey, out var allowed)
+ && !allowed.Any(domain => hostName == domain || hostName.EndsWith("." + domain, StringComparison.Ordinal)))
+ problems.Add(new(path, "invalid-host"));
+ }
+
+ private void CheckEmail(List problems, string path, Field field)
+ {
+ if (!field.Present) return;
+ if (!TryString(field.Node, out var value)) { problems.Add(new(path, "invalid-type")); return; }
+ if (IsBlank(value)) return;
+ if (value.Length > Limit(rules.MaxLength, "email")) { problems.Add(new(path, "too-long")); return; }
+ if (!email.IsMatch(value)) problems.Add(new(path, "invalid-email"));
+ }
+
+ private void CheckList(List problems, string path, Field field, string key, Action checkItem)
+ {
+ if (!field.Present) return;
+ if (field.Node is not JsonArray array) { problems.Add(new(path, "invalid-type")); return; }
+ if (array.Count > Limit(rules.MaxItems, key)) { problems.Add(new(path, "too-many")); return; }
+ for (var i = 0; i < array.Count; i++) checkItem($"{path}[{i}]", array[i]);
+ }
+
+ private static void CheckObjectItem(List problems, string path, JsonNode? item, string[] allowed, Action checkFields)
+ {
+ if (item is not JsonObject source) { problems.Add(new(path, "invalid-type")); return; }
+ CheckUnknownFields(problems, path, source, allowed);
+ checkFields(source);
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj b/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj
index e254ada..1ef4f32 100644
--- a/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj
+++ b/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj
@@ -21,6 +21,7 @@
+
diff --git a/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs b/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs
new file mode 100644
index 0000000..7b3df91
--- /dev/null
+++ b/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs
@@ -0,0 +1,107 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using ProCoderJi.Api.Features.Portfolio;
+using Xunit;
+
+namespace ProCoderJi.Api.Tests;
+
+///
+/// Runs the shared fixtures that the frontend also runs (src/features/portfolio/portfolio.test.ts).
+/// Add a fixture to shared/portfolio/fixtures.json first when a rule changes.
+///
+public sealed class PortfolioValidatorTests
+{
+ private static readonly PortfolioValidator Validator = PortfolioValidator.CreateDefault();
+
+ private static JsonNode Fixtures => JsonNode.Parse(
+ File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data", "portfolio-fixtures.json")))!;
+
+ private static string[] Sorted(IEnumerable problems) =>
+ problems.Select(problem => problem.ToString()).Order(StringComparer.Ordinal).ToArray();
+
+ private static string[] Expected(JsonNode fixture) =>
+ fixture["problems"]!.AsArray().Select(problem => problem!.GetValue()).Order(StringComparer.Ordinal).ToArray();
+
+ [Fact]
+ public void EverySharedUsernameFixtureGivesTheExpectedProblems()
+ {
+ var cases = Fixtures["usernames"]!.AsArray();
+ Assert.NotEmpty(cases);
+ foreach (var fixture in cases)
+ Assert.True(
+ Expected(fixture!).SequenceEqual(Sorted(Validator.ValidateUsername(fixture!["value"]))),
+ $"{fixture["name"]}: expected [{string.Join(", ", Expected(fixture))}] but got [{string.Join(", ", Sorted(Validator.ValidateUsername(fixture["value"])))}]");
+ }
+
+ [Fact]
+ public void EverySharedPortfolioFixtureGivesTheExpectedProblems()
+ {
+ var cases = Fixtures["portfolios"]!.AsArray();
+ Assert.NotEmpty(cases);
+ foreach (var fixture in cases)
+ {
+ var mode = fixture!["mode"]!.GetValue() switch
+ {
+ "draft" => PortfolioMode.Draft,
+ "publish" => PortfolioMode.Publish,
+ var other => throw new InvalidDataException($"Unknown fixture mode: {other}")
+ };
+ var actual = Sorted(Validator.Validate(fixture["value"], mode));
+ Assert.True(
+ Expected(fixture).SequenceEqual(actual),
+ $"{fixture["name"]}: expected [{string.Join(", ", Expected(fixture))}] but got [{string.Join(", ", actual)}]");
+ }
+ }
+
+ [Fact]
+ public void ValidatingDoesNotModifyTheInput()
+ {
+ var input = JsonNode.Parse("""{"name":"Riya","skills":["a"],"links":{"github":"https://github.com/riya"}}""")!;
+ var before = input.ToJsonString();
+ Validator.Validate(input, PortfolioMode.Publish);
+ Assert.Equal(before, input.ToJsonString());
+ }
+
+ [Fact]
+ public void ValidUsernamesAndPortfoliosReportNothing()
+ {
+ Assert.Empty(Validator.ValidateUsername("riya-sharma"));
+ Assert.Empty(Validator.Validate(JsonNode.Parse("""{"name":"Riya","headline":"Student"}"""), PortfolioMode.Publish));
+ }
+
+ [Fact]
+ public void ReservedNamesCoverTheNamesListedInTheDecisionRecord()
+ {
+ foreach (var name in new[] { "www", "api", "app", "admin", "mail", "docs", "blog", "login", "support", "help", "procoderji", "satyam", "learn", "practice", "account", "dashboard", "games", "guides", "jobs" })
+ Assert.Contains(name, PortfolioRulesForTest().Username.Reserved);
+ }
+
+ [Fact]
+ public void IncompleteRulesAreRejectedAtStartup()
+ {
+ Assert.Throws(() => PortfolioRules.FromJson("{}"));
+ Assert.Throws(() => PortfolioRules.FromJson("""{"version":2}"""));
+ Assert.Throws(() => PortfolioRules.FromJson("not json"));
+ }
+
+ [Fact]
+ public void AMissingRuleKeyFailsLoudlyInsteadOfSkippingTheCheck()
+ {
+ var rules = JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data", "portfolio-rules.json")))!.AsObject();
+ rules["maxLength"]!.AsObject().Remove("name");
+ var validator = new PortfolioValidator(PortfolioRules.FromJson(rules.ToJsonString()));
+ Assert.Throws(() => validator.Validate(JsonNode.Parse("""{"name":"Riya"}"""), PortfolioMode.Draft));
+ }
+
+ [Fact]
+ public void AHugeValueIsRejectedQuickly()
+ {
+ var started = DateTime.UtcNow;
+ var huge = JsonNode.Parse(JsonSerializer.Serialize(new { links = new { github = "https://" + new string('a', 2_000_000) } }));
+ Assert.Equal(["links.github:too-long"], Sorted(Validator.Validate(huge, PortfolioMode.Draft)));
+ Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(2));
+ }
+
+ private static PortfolioRules PortfolioRulesForTest() =>
+ PortfolioRules.LoadDefault();
+}
diff --git a/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj b/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj
index c3ef368..9fef073 100644
--- a/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj
+++ b/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj
@@ -20,6 +20,10 @@
+
+
+
+
From a284af65206ba5fadf9fcd9b634b1522feaf9e21 Mon Sep 17 00:00:00 2001
From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com>
Date: Tue, 10 Nov 2026 17:07:55 +0530
Subject: [PATCH 4/4] fix(portfolio): enforce limits on blank text and reject
malformed UTF-16
Check the length before the blank test, so a field of only spaces can
no longer exceed its limit. Previously an 1001-space about, a 301-space
website or a 255-space email passed validation.
Treat a lone surrogate half as invalid-characters in values, links,
emails, usernames and field names. System.Text.Json decodes strings and
property names lazily and throws when they are first read, so the .NET
validator now reads them without throwing and reports the same problem
as the TypeScript validator, which previously accepted them.
Add shared fixtures for both cases. The malformed-text cases list UTF-16
code units because a lone surrogate escape is not portable JSON.
Refs #42
---
.../Features/Portfolio/PortfolioValidator.cs | 104 ++++-
.../PortfolioValidatorTests.cs | 58 +++
shared/portfolio/README.md | 10 +
shared/portfolio/fixtures.json | 370 ++++++++++++++++++
src/features/portfolio/portfolio.test.ts | 41 ++
src/features/portfolio/validation.ts | 47 ++-
6 files changed, 600 insertions(+), 30 deletions(-)
diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs
index 31f3005..1f6219b 100644
--- a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs
+++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs
@@ -61,7 +61,8 @@ public IReadOnlyList ValidateUsername(JsonNode? node)
{
const string path = "username";
var rule = rules.Username;
- if (!TryString(node, out var value)) return [new(path, "invalid-type")];
+ var read = new List();
+ if (!Read(read, path, node, out var value)) return read;
if (IsBlank(value)) return [new(path, "required")];
if (value.Length < rule.MinLength) return [new(path, "too-short")];
if (value.Length > rule.MaxLength) return [new(path, "too-long")];
@@ -81,7 +82,7 @@ public IReadOnlyList Validate(JsonNode? node, PortfolioMode mo
{
var problems = new List();
if (node is not JsonObject root) return [new("$", "invalid-type")];
- CheckUnknownFields(problems, "", root, TopLevelFields);
+ if (!CheckUnknownFields(problems, "", root, TopLevelFields)) return problems;
bool RequiredOnPublish(string key) => mode == PortfolioMode.Publish && rules.RequiredForPublish.Contains(key);
CheckText(problems, "name", Get(root, "name"), "name", RequiredOnPublish("name"));
@@ -126,9 +127,8 @@ public IReadOnlyList Validate(JsonNode? node, PortfolioMode mo
if (links.Present)
{
if (links.Node is not JsonObject linkObject) problems.Add(new("links", "invalid-type"));
- else
+ else if (CheckUnknownFields(problems, "links", linkObject, ["github", "linkedin", "website", "email"]))
{
- CheckUnknownFields(problems, "links", linkObject, ["github", "linkedin", "website", "email"]);
CheckLink(problems, "links.github", Get(linkObject, "github"), "links.github");
CheckLink(problems, "links.linkedin", Get(linkObject, "linkedin"), "links.linkedin");
CheckLink(problems, "links.website", Get(linkObject, "website"), null);
@@ -143,15 +143,60 @@ public IReadOnlyList Validate(JsonNode? node, PortfolioMode mo
private static Field Get(JsonObject source, string key) =>
source.TryGetPropertyValue(key, out var node) ? new(true, node) : new(false, null);
- private static bool TryString(JsonNode? node, out string value)
+ private enum StringRead { Ok, NotAString, Malformed }
+
+ ///
+ /// Reads a JSON string without throwing. Depending on the runtime, a lazily decoded string
+ /// that holds a lone surrogate throws while it is read; that and a programmatically built
+ /// string with a lone surrogate are both reported as malformed.
+ ///
+ private static StringRead TryString(JsonNode? node, out string value)
{
- if (node is JsonValue json && json.TryGetValue(out var text) && text is not null)
+ value = "";
+ if (node is not JsonValue json) return StringRead.NotAString;
+ string? text;
+ try
{
- value = text;
- return true;
+ if (!json.TryGetValue(out text) || text is null) return StringRead.NotAString;
+ }
+ catch (Exception cause) when (cause is InvalidOperationException or ArgumentException or FormatException)
+ {
+ return StringRead.Malformed;
+ }
+ if (!IsWellFormed(text)) return StringRead.Malformed;
+ value = text;
+ return StringRead.Ok;
+ }
+
+ /// Adds the problem for an unreadable value; returns true when the value was read.
+ private static bool Read(List problems, string path, JsonNode? node, out string value)
+ {
+ switch (TryString(node, out value))
+ {
+ case StringRead.Ok:
+ return true;
+ case StringRead.Malformed:
+ problems.Add(new(path, "invalid-characters"));
+ return false;
+ default:
+ problems.Add(new(path, "invalid-type"));
+ return false;
}
- value = "";
- return false;
+ }
+
+ /// False when the text has a lone surrogate half, which no encoding can represent.
+ private static bool IsWellFormed(string text)
+ {
+ for (var i = 0; i < text.Length; i++)
+ {
+ if (char.IsHighSurrogate(text[i]))
+ {
+ if (i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1])) return false;
+ i++;
+ }
+ else if (char.IsLowSurrogate(text[i])) return false;
+ }
+ return true;
}
private static bool IsBlank(string value) => value.All(c => c is ' ' or '\t' or '\r' or '\n');
@@ -167,10 +212,29 @@ private static string AsciiLower(string value) =>
private int Limit(IReadOnlyDictionary table, string key) =>
table.TryGetValue(key, out var value) ? value : throw new InvalidDataException($"Missing portfolio rule: {key}");
- private static void CheckUnknownFields(List problems, string path, JsonObject source, IReadOnlyCollection allowed)
+ ///
+ /// Reports unknown field names. Returns false, after reporting invalid-characters for the
+ /// object itself, when a field name is not well-formed text; the caller then skips that object.
+ /// System.Text.Json decodes property names lazily, so a lone surrogate escape in a parsed name
+ /// throws the first time the object is read.
+ ///
+ private static bool CheckUnknownFields(List problems, string path, JsonObject source, IReadOnlyCollection allowed)
{
- foreach (var (key, _) in source)
+ List keys;
+ try { keys = source.Select(property => property.Key).ToList(); }
+ catch (Exception cause) when (cause is InvalidOperationException or ArgumentException or FormatException)
+ {
+ problems.Add(new(path.Length == 0 ? "$" : path, "invalid-characters"));
+ return false;
+ }
+ if (keys.Any(key => !IsWellFormed(key)))
+ {
+ problems.Add(new(path.Length == 0 ? "$" : path, "invalid-characters"));
+ return false;
+ }
+ foreach (var key in keys)
if (!allowed.Contains(key)) problems.Add(new(path.Length == 0 ? key : $"{path}.{key}", "unknown-field"));
+ return true;
}
private void CheckText(List problems, string path, Field field, string key, bool required)
@@ -180,13 +244,14 @@ private void CheckText(List problems, string path, Field field
if (required) problems.Add(new(path, "required"));
return;
}
- if (!TryString(field.Node, out var value)) { problems.Add(new(path, "invalid-type")); return; }
+ if (!Read(problems, path, field.Node, out var value)) return;
+ // Length comes before the blank check, so a field of only spaces cannot exceed its limit.
+ if (value.Length > Limit(rules.MaxLength, key)) { problems.Add(new(path, "too-long")); return; }
if (IsBlank(value))
{
if (required) problems.Add(new(path, "required"));
return;
}
- if (value.Length > Limit(rules.MaxLength, key)) { problems.Add(new(path, "too-long")); return; }
if (control.IsMatch(value) || (!multiline.Contains(key) && value.AsSpan().IndexOfAny('\r', '\n') >= 0))
{
problems.Add(new(path, "invalid-characters"));
@@ -199,9 +264,9 @@ private void CheckText(List problems, string path, Field field
private void CheckLink(List problems, string path, Field field, string? hostKey)
{
if (!field.Present) return;
- if (!TryString(field.Node, out var value)) { problems.Add(new(path, "invalid-type")); return; }
- if (IsBlank(value)) return;
+ if (!Read(problems, path, field.Node, out var value)) return;
if (value.Length > Limit(rules.MaxLength, "link")) { problems.Add(new(path, "too-long")); return; }
+ if (IsBlank(value)) return;
var scheme = rules.Link.Scheme;
if (linkForbidden.IsMatch(value) || value.Length < scheme.Length || AsciiLower(value[..scheme.Length]) != scheme)
{
@@ -223,9 +288,9 @@ private void CheckLink(List problems, string path, Field field
private void CheckEmail(List problems, string path, Field field)
{
if (!field.Present) return;
- if (!TryString(field.Node, out var value)) { problems.Add(new(path, "invalid-type")); return; }
- if (IsBlank(value)) return;
+ if (!Read(problems, path, field.Node, out var value)) return;
if (value.Length > Limit(rules.MaxLength, "email")) { problems.Add(new(path, "too-long")); return; }
+ if (IsBlank(value)) return;
if (!email.IsMatch(value)) problems.Add(new(path, "invalid-email"));
}
@@ -240,7 +305,6 @@ private void CheckList(List problems, string path, Field field
private static void CheckObjectItem(List problems, string path, JsonNode? item, string[] allowed, Action checkFields)
{
if (item is not JsonObject source) { problems.Add(new(path, "invalid-type")); return; }
- CheckUnknownFields(problems, path, source, allowed);
- checkFields(source);
+ if (CheckUnknownFields(problems, path, source, allowed)) checkFields(source);
}
}
diff --git a/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs b/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs
index 7b3df91..37a1e4c 100644
--- a/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs
+++ b/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs
@@ -53,6 +53,64 @@ public void EverySharedPortfolioFixtureGivesTheExpectedProblems()
}
}
+ // Text with a lone surrogate cannot be stored in JSON that System.Text.Json parses, so these
+ // fixtures list the UTF-16 code units and each runtime builds the string itself.
+ [Fact]
+ public void EveryMalformedTextFixtureGivesTheExpectedProblems()
+ {
+ var cases = Fixtures["malformedText"]!.AsArray();
+ Assert.NotEmpty(cases);
+ foreach (var fixture in cases)
+ {
+ var text = new string(fixture!["codeUnits"]!.AsArray().Select(unit => (char)unit!.GetValue()).ToArray());
+ var field = fixture["field"]!.GetValue();
+ var actual = Sorted(field == "username"
+ ? Validator.ValidateUsername(text)
+ : Validator.Validate(PortfolioWith(field, text), PortfolioMode.Draft));
+ Assert.True(
+ Expected(fixture).SequenceEqual(actual),
+ $"{fixture["name"]}: expected [{string.Join(", ", Expected(fixture))}] but got [{string.Join(", ", actual)}]");
+ }
+ }
+
+ private static JsonNode PortfolioWith(string field, string text) => field switch
+ {
+ "name" => new JsonObject { ["name"] = text },
+ "about" => new JsonObject { ["about"] = text },
+ "skill" => new JsonObject { ["skills"] = new JsonArray(text) },
+ "projectLink" => new JsonObject { ["projects"] = new JsonArray(new JsonObject { ["title"] = "App", ["link"] = text }) },
+ "website" => new JsonObject { ["links"] = new JsonObject { ["website"] = text } },
+ "email" => new JsonObject { ["links"] = new JsonObject { ["email"] = text } },
+ "key" => new JsonObject { [text] = 1 },
+ "linksKey" => new JsonObject { ["links"] = new JsonObject { [text] = 1 } },
+ "projectKey" => new JsonObject { ["projects"] = new JsonArray(new JsonObject { ["title"] = "App", [text] = 1 }) },
+ _ => throw new InvalidDataException($"Unknown fixture field: {field}")
+ };
+
+ [Fact]
+ public void ALoneSurrogateEscapeInParsedJsonIsAProblemNotAnException()
+ {
+ // JsonNode.Parse accepts the text; System.Text.Json only fails later, when a string or a
+ // property name is read, so the validator has to handle the read failing.
+ var json = "{\"name\":\"" + Escape("d800") + "\",\"skills\":[\"" + Escape("dc00") + "\"]}";
+ Assert.Equal(
+ ["name:invalid-characters", "skills[0]:invalid-characters"],
+ Sorted(Validator.Validate(JsonNode.Parse(json), PortfolioMode.Draft)));
+ }
+
+ [Theory]
+ [InlineData("{\"{0}\":1}", "$:invalid-characters")]
+ [InlineData("{\"links\":{\"{0}\":1}}", "links:invalid-characters")]
+ [InlineData("{\"projects\":[{\"title\":\"App\",\"{0}\":1}]}", "projects[0]:invalid-characters")]
+ public void ALoneSurrogateEscapeInAParsedFieldNameIsAProblemNotAnException(string template, string expected)
+ {
+ var node = JsonNode.Parse(template.Replace("{0}", Escape("d800"), StringComparison.Ordinal));
+ Assert.Equal([expected], Sorted(Validator.Validate(node, PortfolioMode.Draft)));
+ }
+
+ // The backslash is built from a char code so this source never contains a surrogate escape.
+ private static string Escape(string hex) => (char)92 + "u" + hex;
+
[Fact]
public void ValidatingDoesNotModifyTheInput()
{
diff --git a/shared/portfolio/README.md b/shared/portfolio/README.md
index dfde2e6..e99b0fc 100644
--- a/shared/portfolio/README.md
+++ b/shared/portfolio/README.md
@@ -37,6 +37,16 @@ The codes are `required`, `invalid-type`, `unknown-field`, `too-short`, `too-lon
`invalid-characters`, `html-like`, `invalid-format`, `invalid-link`, `invalid-host`, `invalid-email`,
`reserved` and `blocked-word`. Only the first failing check is reported for each field.
+Checks run in a fixed order for each field: type, well-formed text, length, blank, then the content rules. The length
+check comes before the blank check, so a field of only spaces cannot exceed its limit.
+
+**Malformed text.** A lone surrogate (half of a UTF-16 pair, such as the text a broken emoji leaves behind) cannot be
+stored or sent, so it is `invalid-characters` in a value, a link, an email or a username. In a field name it is
+reported as `invalid-characters` on the object that holds it, and that object's other fields are skipped.
+System.Text.Json decodes strings and field names lazily, so the .NET validator reads them without throwing and
+reports the same problem. A lone surrogate escape is not portable JSON (editors and many parsers reject it), so the
+`malformedText` fixtures list UTF-16 code units and each runtime builds the string itself.
+
## Limits
Lengths count UTF-16 code units, the unit both JavaScript and .NET use, so one emoji counts as 2.
diff --git a/shared/portfolio/fixtures.json b/shared/portfolio/fixtures.json
index b63fa65..cb4672a 100644
--- a/shared/portfolio/fixtures.json
+++ b/shared/portfolio/fixtures.json
@@ -1884,6 +1884,125 @@
"links.email:invalid-email"
]
},
+ {
+ "name": "about of only spaces at the limit",
+ "mode": "draft",
+ "value": {
+ "about": " "
+ },
+ "problems": []
+ },
+ {
+ "name": "about of only spaces over the limit",
+ "mode": "draft",
+ "value": {
+ "about": " "
+ },
+ "problems": [
+ "about:too-long"
+ ]
+ },
+ {
+ "name": "name of only spaces over the limit (draft)",
+ "mode": "draft",
+ "value": {
+ "name": " "
+ },
+ "problems": [
+ "name:too-long"
+ ]
+ },
+ {
+ "name": "name of only spaces over the limit (publish)",
+ "mode": "publish",
+ "value": {
+ "name": " ",
+ "headline": "Student"
+ },
+ "problems": [
+ "name:too-long"
+ ]
+ },
+ {
+ "name": "name of only spaces at the limit is still required to publish",
+ "mode": "publish",
+ "value": {
+ "name": " ",
+ "headline": "Student"
+ },
+ "problems": [
+ "name:required"
+ ]
+ },
+ {
+ "name": "skill of only spaces over the limit",
+ "mode": "draft",
+ "value": {
+ "skills": [
+ " "
+ ]
+ },
+ "problems": [
+ "skills[0]:too-long"
+ ]
+ },
+ {
+ "name": "project link of only spaces over the limit",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": " "
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:too-long"
+ ]
+ },
+ {
+ "name": "website of only spaces over the limit",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "website": " "
+ }
+ },
+ "problems": [
+ "links.website:too-long"
+ ]
+ },
+ {
+ "name": "website of only spaces at the limit",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "website": " "
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "email of only spaces over the limit",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": " "
+ }
+ },
+ "problems": [
+ "links.email:too-long"
+ ]
+ },
{
"name": "several problems are all reported",
"mode": "publish",
@@ -1912,5 +2031,256 @@
"links.github:invalid-host"
]
}
+ ],
+ "malformedText": [
+ {
+ "name": "lone high surrogate in a name",
+ "field": "name",
+ "codeUnits": [
+ 97,
+ 55296,
+ 98
+ ],
+ "problems": [
+ "name:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone high surrogate at the end",
+ "field": "name",
+ "codeUnits": [
+ 97,
+ 55296
+ ],
+ "problems": [
+ "name:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone low surrogate",
+ "field": "name",
+ "codeUnits": [
+ 56320
+ ],
+ "problems": [
+ "name:invalid-characters"
+ ]
+ },
+ {
+ "name": "low surrogate before a high one",
+ "field": "name",
+ "codeUnits": [
+ 56320,
+ 55296
+ ],
+ "problems": [
+ "name:invalid-characters"
+ ]
+ },
+ {
+ "name": "valid surrogate pair is fine",
+ "field": "name",
+ "codeUnits": [
+ 97,
+ 55357,
+ 56832,
+ 98
+ ],
+ "problems": []
+ },
+ {
+ "name": "lone surrogate in a multi-line field",
+ "field": "about",
+ "codeUnits": [
+ 108,
+ 105,
+ 110,
+ 101,
+ 32,
+ 111,
+ 110,
+ 101,
+ 10,
+ 55296
+ ],
+ "problems": [
+ "about:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a skill",
+ "field": "skill",
+ "codeUnits": [
+ 56320
+ ],
+ "problems": [
+ "skills[0]:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a project link",
+ "field": "projectLink",
+ "codeUnits": [
+ 104,
+ 116,
+ 116,
+ 112,
+ 115,
+ 58,
+ 47,
+ 47,
+ 101,
+ 120,
+ 97,
+ 109,
+ 112,
+ 108,
+ 101,
+ 46,
+ 99,
+ 111,
+ 109,
+ 47,
+ 55296
+ ],
+ "problems": [
+ "projects[0].link:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a website",
+ "field": "website",
+ "codeUnits": [
+ 104,
+ 116,
+ 116,
+ 112,
+ 115,
+ 58,
+ 47,
+ 47,
+ 101,
+ 120,
+ 97,
+ 109,
+ 112,
+ 108,
+ 101,
+ 46,
+ 99,
+ 111,
+ 109,
+ 47,
+ 56320
+ ],
+ "problems": [
+ "links.website:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in an email",
+ "field": "email",
+ "codeUnits": [
+ 114,
+ 105,
+ 121,
+ 97,
+ 64,
+ 101,
+ 120,
+ 97,
+ 109,
+ 112,
+ 108,
+ 101,
+ 46,
+ 99,
+ 111,
+ 109,
+ 55296
+ ],
+ "problems": [
+ "links.email:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a username",
+ "field": "username",
+ "codeUnits": [
+ 97,
+ 98,
+ 99,
+ 55296
+ ],
+ "problems": [
+ "username:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a field name",
+ "field": "key",
+ "codeUnits": [
+ 97,
+ 55296
+ ],
+ "problems": [
+ "$:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone low surrogate as a whole field name",
+ "field": "key",
+ "codeUnits": [
+ 56320
+ ],
+ "problems": [
+ "$:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a field name inside links",
+ "field": "linksKey",
+ "codeUnits": [
+ 55296
+ ],
+ "problems": [
+ "links:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a project field name",
+ "field": "projectKey",
+ "codeUnits": [
+ 56320
+ ],
+ "problems": [
+ "projects[0]:invalid-characters"
+ ]
+ },
+ {
+ "name": "valid pair in a field name is only an unknown field",
+ "field": "key",
+ "codeUnits": [
+ 97,
+ 55357,
+ 56832
+ ],
+ "problems": [
+ "a\ud83d\ude00:unknown-field"
+ ]
+ },
+ {
+ "name": "valid pair in a username is well-formed but not allowed",
+ "field": "username",
+ "codeUnits": [
+ 97,
+ 98,
+ 55357,
+ 56832
+ ],
+ "problems": [
+ "username:invalid-format"
+ ]
+ }
]
}
diff --git a/src/features/portfolio/portfolio.test.ts b/src/features/portfolio/portfolio.test.ts
index 8cb00d5..1d632ce 100644
--- a/src/features/portfolio/portfolio.test.ts
+++ b/src/features/portfolio/portfolio.test.ts
@@ -33,6 +33,47 @@ test("every shared portfolio fixture gives the expected problems", () => {
);
});
+// Text with a lone surrogate cannot be stored in JSON that .NET parses, so these fixtures list
+// the UTF-16 code units and each runtime builds the string itself.
+test("every malformed-text fixture gives the expected problems", () => {
+ assert.ok(fixtures.malformedText.length > 0);
+ for (const fixture of fixtures.malformedText) {
+ const text = String.fromCharCode(...fixture.codeUnits);
+ const problems =
+ fixture.field === "username"
+ ? validateUsername(text)
+ : validatePortfolio(portfolioWith(fixture.field, text), "draft");
+ assert.deepEqual(keys(problems), [...fixture.problems].sort(), fixture.name);
+ }
+});
+
+function portfolioWith(field: string, text: string): unknown {
+ switch (field) {
+ case "name": return { name: text };
+ case "about": return { about: text };
+ case "skill": return { skills: [text] };
+ case "projectLink": return { projects: [{ title: "App", link: text }] };
+ case "website": return { links: { website: text } };
+ case "email": return { links: { email: text } };
+ case "key": return { [text]: 1 };
+ case "linksKey": return { links: { [text]: 1 } };
+ case "projectKey": return { projects: [{ title: "App", [text]: 1 }] };
+ default: throw new Error(`Unknown fixture field: ${field}`);
+ }
+}
+
+test("lone surrogate escapes in parsed JSON text are problems", () => {
+ // The backslash comes from a char code so this source never holds a surrogate escape.
+ const escape = (hex: string) => String.fromCharCode(92) + "u" + hex;
+ const values = '{"name":"' + escape("d800") + '","skills":["' + escape("dc00") + '"]}';
+ assert.deepEqual(keys(validatePortfolio(JSON.parse(values), "draft")), [
+ "name:invalid-characters",
+ "skills[0]:invalid-characters",
+ ]);
+ const name = '{"links":{"' + escape("d800") + '":1}}';
+ assert.deepEqual(keys(validatePortfolio(JSON.parse(name), "draft")), ["links:invalid-characters"]);
+});
+
test("the fixtures cover valid and invalid input and both modes", () => {
assert.ok(fixtures.usernames.some((f) => f.problems.length === 0));
assert.ok(fixtures.usernames.some((f) => f.problems.length > 0));
diff --git a/src/features/portfolio/validation.ts b/src/features/portfolio/validation.ts
index 7ee620d..7c26a62 100644
--- a/src/features/portfolio/validation.ts
+++ b/src/features/portfolio/validation.ts
@@ -44,6 +44,19 @@ function asciiLower(text: string): string {
return text.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
}
+/** False when the text has a lone surrogate half, which no encoding can represent. */
+function isWellFormed(text: string): boolean {
+ for (let i = 0; i < text.length; i++) {
+ const unit = text.charCodeAt(i);
+ if (unit >= 0xd800 && unit <= 0xdbff) {
+ const next = text.charCodeAt(i + 1);
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
+ i++;
+ } else if (unit >= 0xdc00 && unit <= 0xdfff) return false;
+ }
+ return true;
+}
+
function isObject(value: Value): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -52,15 +65,25 @@ function get(object: Record, key: string): Value {
return Object.hasOwn(object, key) ? object[key] : undefined;
}
+/**
+ * Reports unknown field names. Returns false, after reporting `invalid-characters` for the
+ * object itself, when a field name is not well-formed text; the caller then skips that object.
+ */
function checkUnknownFields(
problems: Problems,
path: string,
object: Record,
allowed: readonly string[],
-) {
- for (const key of Object.keys(object))
+): boolean {
+ const keys = Object.keys(object);
+ if (keys.some((key) => !isWellFormed(key))) {
+ add(problems, path || "$", "invalid-characters");
+ return false;
+ }
+ for (const key of keys)
if (!allowed.includes(key))
add(problems, path ? `${path}.${key}` : key, "unknown-field");
+ return true;
}
function checkText(
@@ -75,11 +98,13 @@ function checkText(
return;
}
if (typeof value !== "string") return add(problems, path, "invalid-type");
+ if (!isWellFormed(value)) return add(problems, path, "invalid-characters");
+ // Length comes before the blank check, so a field of only spaces cannot exceed its limit.
+ if (value.length > limit(RULES.maxLength, key)) return add(problems, path, "too-long");
if (BLANK.test(value)) {
if (required) add(problems, path, "required");
return;
}
- if (value.length > limit(RULES.maxLength, key)) return add(problems, path, "too-long");
if (CONTROL.test(value) || (!MULTILINE.has(key) && LINE_BREAK.test(value)))
return add(problems, path, "invalid-characters");
if (HTML_LIKE.test(value)) add(problems, path, "html-like");
@@ -94,8 +119,9 @@ function checkLink(
) {
if (value === undefined) return;
if (typeof value !== "string") return add(problems, path, "invalid-type");
- if (BLANK.test(value)) return;
+ if (!isWellFormed(value)) return add(problems, path, "invalid-characters");
if (value.length > limit(RULES.maxLength, "link")) return add(problems, path, "too-long");
+ if (BLANK.test(value)) return;
const scheme = RULES.link.scheme;
if (
LINK_FORBIDDEN.test(value) ||
@@ -117,8 +143,9 @@ function checkLink(
function checkEmail(problems: Problems, path: string, value: Value) {
if (value === undefined) return;
if (typeof value !== "string") return add(problems, path, "invalid-type");
- if (BLANK.test(value)) return;
+ if (!isWellFormed(value)) return add(problems, path, "invalid-characters");
if (value.length > limit(RULES.maxLength, "email")) return add(problems, path, "too-long");
+ if (BLANK.test(value)) return;
if (!EMAIL.test(value)) add(problems, path, "invalid-email");
}
@@ -143,8 +170,7 @@ function checkObjectItem(
checkFields: (object: Record) => void,
) {
if (!isObject(item)) return add(problems, path, "invalid-type");
- checkUnknownFields(problems, path, item, allowed);
- checkFields(item);
+ if (checkUnknownFields(problems, path, item, allowed)) checkFields(item);
}
/** Checks a username for the public address `/u/`. */
@@ -153,6 +179,7 @@ export function validateUsername(value: unknown): PortfolioProblem[] {
const path = "username";
const rules = RULES.username;
if (typeof value !== "string") return [{ path, code: "invalid-type" }];
+ if (!isWellFormed(value)) return [{ path, code: "invalid-characters" }];
if (BLANK.test(value)) return [{ path, code: "required" }];
if (value.length < rules.minLength) return [{ path, code: "too-short" }];
if (value.length > rules.maxLength) return [{ path, code: "too-long" }];
@@ -174,7 +201,7 @@ export function validatePortfolio(
): PortfolioProblem[] {
const problems: Problems = [];
if (!isObject(value)) return [{ path: "$", code: "invalid-type" }];
- checkUnknownFields(problems, "", value, [
+ const rootOk = checkUnknownFields(problems, "", value, [
"name",
"headline",
"about",
@@ -185,6 +212,7 @@ export function validatePortfolio(
"achievements",
"links",
]);
+ if (!rootOk) return problems;
const requiredOnPublish = (key: string) =>
mode === "publish" && RULES.requiredForPublish.includes(key);
@@ -232,8 +260,7 @@ export function validatePortfolio(
const links = get(value, "links");
if (links !== undefined) {
if (!isObject(links)) add(problems, "links", "invalid-type");
- else {
- checkUnknownFields(problems, "links", links, ["github", "linkedin", "website", "email"]);
+ else if (checkUnknownFields(problems, "links", links, ["github", "linkedin", "website", "email"])) {
checkLink(problems, "links.github", get(links, "github"), "links.github");
checkLink(problems, "links.linkedin", get(links, "linkedin"), "links.linkedin");
checkLink(problems, "links.website", get(links, "website"));