From 7e9d7058557391f394b00c186bad1032640a8830 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sun, 27 Sep 2026 13:38:15 +0330 Subject: [PATCH 1/7] fix(pasarguard,flags): name the page after its subscriber, and read alpha-2 codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found while hardening 1.3.0 against the panels' own source, plus the audit trail for both. Nothing changes for an existing 3X-UI install, and no adapter or panel-adapter behaviour is involved. PasarGuard: the page titled itself "Subscription" PasarGuard's page context is `{user, links, announce, announce_url, apps}` plus Jinja2's `now` -- the panel-wide subscription settings are NOT in it. The prelude read `subTitle` from nothing, so every PasarGuard page fell back to the generic word. The page is now named by the admin's configured profile title when there is one, and by the subscriber (`user.username`) otherwise -- the order the panel itself resolves a title in (`SubscriptionOperation._format_profile_title` prefers the admin's `profile_title` over `sub_settings.profile_title`). A profile title that still carries a PasarGuard format placeholder (`{USERNAME}`, `{DATA_LIMIT}`, ...) is refused rather than printed literally: the page cannot resolve those variables, and a literal placeholder is worse than the subscriber's own name. `user.ip` is still never read. `user.username` is the only deterministic subscriber identifier in the context (`email` does not exist; `id`, `note`, `subscription_url` and `auto_delete_in_days` are `exclude=True`), and `UserValidator.validate_username` bounds it to 3-128 characters of `[a-zA-Z0-9-_@.]`. A separate `subscriber_display_name` island field was designed and rejected on measurement, not on taste: a fourteenth `data-*` attribute plus its model and render paths costs ~157 B, the flag fix below costs 179 B, and `pulsenova` had 274 B of headroom against a ceiling that may not be raised. The two do not fit together. The prelude route costs zero artifact bytes, leaves 3X-UI's Go view model untouched (a missing Go map key would print the literal "" into every 3X-UI page), and keeps the PasarGuard assumption out of the shared rendering. The reasoning is recorded in the prelude header. Country flags: a node name carrying an ISO alpha-2 code found no flag `flagOf` only ever looked for a regional-indicator pair, so `TR | Istanbul`, `RU-01`, `GB-LON-1`, `FI-01` and `DE` returned '' and the row fell to the monogram -- `T-I`, `R-U`, `G-B`, `F-I` -- which reads as the code. This is an INPUT extension only. The return shape is still the regional-indicator pair, `cfg.flag` is still `'🇩🇪'`, `CODES` is still the validity source, the monogram fallback is unchanged and `cleanName` is unchanged, so the Phase 0.6 correction ("no alpha-2 change", which is about the return value) still holds. The gradient workstream's "flagOf() unchanged" row is not a freeze on the input contract. A two-letter token is read as a country only when it is UPPERCASE, stands alone between non-letters, and names a real country in the SAME 258-code registry the emoji path uses. `LON` out of `GB-LON-1`, `USA`, `A1B`, `ZZ-01` and the lowercase English words that are themselves assigned codes (`no`, `it`, `us`, `in`) are all refused. An explicit flag emoji still wins, and the displayed name is never rewritten -- an emoji means a flag and nothing else, but two letters can be an operator's own wording. Country names and city names (`Turkey - Istanbul`, `Türkiye`, `Finland Helsinki`) still draw the monogram. A name table is well over 1 KB against 95 B of remaining headroom, and guessing from an ambiguous name is worse than a monogram; the tests assert the refusal so the boundary is recorded rather than implied. All 15 artifacts are re-baselined: +179 B each, because `flag.js` is in the shared app script. `pulsenova` is 204,705 B with 95 B of headroom, inside the 204,800 B ceiling. The ceiling was not raised and no other bytes were reclaimed. Tests - `tests/flag.test.mjs` (+7): the reported cases; all 676 letter pairs against the 258-code registry; the uppercase/bounded/assigned refusals; emoji-beats-code; a re-assertion that no previous answer moved; the country-name boundary. The existing 13 tests pass verbatim. - `tests/panels-engines.test.mjs` (+10): the subscriber name; the admin's profile title over it; a format placeholder refused; a missing name keeping the generic fallback; no admin at all; hostile HTML; a non-Latin name; a 128-character name; and an assertion that the page context carries exactly `{user, links, announce, announce_url, now}`. - `tests/build.test.mjs`: the 4 individual byte-locks and the 13-row `FROZEN_ARTIFACTS` table. - `tools/engines.mjs`: the PasarGuard page context can now state the admin's own columns (`native.admin`), because the page context is not the /info payload -- the headers carry the values the panel resolved, the context carries the admin's columns and nothing else. Docs - `docs/design/PGCLOCK-AUDIT.md` (new): read-only audit of the third-party PGClock template. It independently confirms `user.username` as the exposed subscriber identity and the `.env` + `CUSTOM_TEMPLATES_DIRECTORY` install mechanism; it has no service-name, support-URL or flag logic; and it is unlicensed (no LICENSE file, no header), so nothing was derived from it and `PROVENANCE.md` is unchanged. - `docs/design/PASARGUARD-ADAPTER-AUDIT.md` §8: the page context is not the /info payload. - `docs/design/FLAG-RENDERER-AUDIT.md` §17: the alpha-2 path, its cost, and the boundary it does not cross. --- CHANGELOG.md | 47 ++++++++ docs/design/FLAG-RENDERER-AUDIT.md | 119 +++++++++++++++++++ docs/design/PASARGUARD-ADAPTER-AUDIT.md | 57 ++++++++- docs/design/PGCLOCK-AUDIT.md | 152 ++++++++++++++++++++++++ docs/design/README.md | 1 + src/panels/pasarguard/prelude.jinja2 | 50 +++++++- src/scripts/flag.js | 21 +++- template/index.html | 10 +- tests/build.test.mjs | 42 +++---- tests/flag.test.mjs | 123 +++++++++++++++++++ tests/panels-engines.test.mjs | 139 +++++++++++++++++++++- tools/engines.mjs | 20 +++- 12 files changed, 749 insertions(+), 32 deletions(-) create mode 100644 docs/design/PGCLOCK-AUDIT.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 557bf31..a3f09d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Fixes found while hardening 1.3.0 against the panels' own source. Nothing changes +for an existing 3X-UI install, and no adapter or panel-behaviour change is +involved. + +### Fixed + +- **PasarGuard: the page no longer titles itself "Subscription".** PasarGuard's + page context carries the subscriber (`user.username`) and the admin's own + columns, but **not** the panel-wide subscription settings, so the template had + nothing to read and every page fell back to the generic word. The page is now + named by the admin's configured profile title when there is one, and by the + subscriber otherwise — the order PasarGuard itself resolves a title in. A + profile title that still carries a PasarGuard format placeholder + (`{DATA_LIMIT}`, `{EXPIRE_DATE}`, `{USERNAME}`, …) is refused rather than + printed literally, because the page cannot resolve those variables and a + literal placeholder is worse than the subscriber's own name. The subscriber's + address (`user.ip`) is still never read. +- **Country flags: a node name that carries an ISO alpha-2 code now gets that + country's flag.** `TR | Istanbul`, `RU-01`, `GB-LON-1`, `FI-01`, `DE` and the + rest previously found no regional-indicator pair and fell to the monogram + (`T-I`, `R-U`, `G-B`, `F-I`) — which on any platform reads as the code, which + is why the badge appeared to show `TR`/`RU`/`GB`/`FI` instead of a flag. A + two-letter token is read as a country only when it is uppercase, stands alone + between non-letters, and names a real country in the **same 258-code registry + the emoji path already uses**; `LON` out of `GB-LON-1`, `USA`, `A1B`, `ZZ-01` + and lowercase English words (`no`, `it`, `us`, `in`) are all still refused. An + explicit flag emoji still wins over a code in the same name, and the displayed + name is never rewritten. Country *names* and *city names* — `Turkey - + Istanbul`, `Türkiye`, `Turkiye`, `Finland Helsinki` — still draw the monogram: + a name table does not fit the frozen artifact budget, and guessing from an + ambiguous name is worse than a monogram. All 15 artifacts were re-baselined + (+179 B each; `pulsenova` is 204,705 B with 95 B of headroom). + +### Documentation + +- `docs/design/PGCLOCK-AUDIT.md` — a read-only audit of the third-party PGClock + template as a secondary reference. It confirms `user.username` as the exposed + subscriber identity, has no service-name, support-URL or flag logic, and is + unlicensed, so nothing was derived from it and `PROVENANCE.md` is unchanged. +- `docs/design/PASARGUARD-ADAPTER-AUDIT.md` §8 — the page context is not the + `/info` payload: the headers carry the values the panel *resolved*, the page + context carries the admin's own columns and nothing else. +- `docs/design/FLAG-RENDERER-AUDIT.md` §17 — the alpha-2 path is an **input** + extension; the return shape, `CODES` and every fallback are unchanged. + ## [1.3.0] - 2026-09-26 Row-Template now installs on **PasarGuard** and **Rebecca** as well as 3X-UI, diff --git a/docs/design/FLAG-RENDERER-AUDIT.md b/docs/design/FLAG-RENDERER-AUDIT.md index 9f16d5c..f07f91f 100644 --- a/docs/design/FLAG-RENDERER-AUDIT.md +++ b/docs/design/FLAG-RENDERER-AUDIT.md @@ -976,3 +976,122 @@ See the **Phase 1 status** block at the head of this document for the shipped fi for the one gate that is *not* satisfied on exit: the §10 ≥ 1 KB headroom floor. FLAG RENDERER IMPLEMENTED — COMMITTED AT `4a95cbc` — PREVIEWS REFRESHED AT `a012108` + +--- + +## 17. Input-side alpha-2 support — 1.3.0 hardening (added 2026-09-27) + +**Status: IMPLEMENTED.** `flagOf` gained an **input** form. Its **return shape, +its registry and its fallbacks are unchanged.** + +### 17.1 The defect + +Operators name nodes with the country as an **ISO 3166-1 alpha-2 code**, not as a +flag emoji: `TR | Istanbul`, `RU-01`, `GB-LON-1`, `FI-01`, `DE`. `flagOf` only +ever looked for a **regional-indicator pair** (`U+1F1E6`–`U+1F1FF`), so all of +these returned `''` and the row fell to the **monogram** — `T-I`, `R-U`, `G-B`, +`F-I`. On Windows, where the emoji path itself already degrades to letters (§2), +the two states look alike, which is why the defect read as "the badge shows +`TR`/`RU`/`GB`/`FI` instead of a flag". + +### 17.2 Why this does not contradict Correction B + +Correction B in the Phase 0.6 table above reads *"`flagOf()` keeps returning the +**regional-indicator pair** — no alpha-2 change"*, and §5 rule 2 expands it: +*"`flagOf` keeps its public shape. It returns the **regional-indicator pair**, +not an alpha-2 code. `cfg.flag` stays `'🇩🇪'`."* + +That rule is about the **output**: the function must not start returning `'DE'` +instead of `'🇩🇪'`, because `cfg.flag` is the key into `FLAGS` and the text of the +badge. It is preserved exactly: + +| Contract | Before | After | +|---|---|---| +| return value | a regional-indicator pair, or `''` | **unchanged** | +| `cfg.flag` | `'🇩🇪'` | **unchanged** | +| `CODES` | the validity source, 258 assigned codes | **unchanged** | +| the monogram fallback | `flagOf(name) === ''` | **unchanged** | +| `cleanName` | strips indicators, tidies separators | **unchanged** | +| existing tests | 10 in `tests/flag.test.mjs` | **all pass verbatim** | + +What changed is the **input** vocabulary: a bare uppercase two-letter token is now +read as a code *in addition to* the emoji scan. §8's `flagOf()` **unchanged** row +belongs to the gradient workstream, which never had cause to touch the input side; +it is not a freeze on the function's input contract. + +### 17.3 The rule, and why it is narrow + +A two-letter run is read as a code only when it is **uppercase** and **stands +alone between non-letters**: + +```js +const CODE = /(?:^|[^A-Z])([A-Z]{2})(?![A-Z])/; +``` + +- **Uppercase only.** A case-insensitive match would make `no`, `it`, `us`, `in`, + `at`, `be`, `so` and `do` — all assigned codes — match inside ordinary English + node names (`no flag here`, `it support`, `us east`). Every one of those is + pinned as a refusal in the tests. +- **Bounded on both sides.** `LON` out of `GB-LON-1` is not a code, `USA` is not + a code, and `A1B` is not a code. `_` counts as a separator, so `TR_01` works. +- **Membership is `CODES`.** An unassigned pair (`ZZ`, `QQ`, `XX`) is refused + exactly as it is on the emoji path — the same 258-code registry, so the two + paths can never disagree about what a real country is. `UK` is accepted because + the registry already carries it as exceptionally reserved; `UK London` therefore + resolves to the UK flag emoji while `GB-LON-1` resolves to the GB one. Both are + real, and that is the registry's own doctrine, not a new decision. +- **The emoji wins.** An explicit flag emoji is the more specific claim, so it is + still preferred when both appear in one name. +- **The label is never rewritten.** An emoji means a flag and nothing else, but + two uppercase letters can be an operator's own wording, so the code is read and + **not** stripped from the displayed name. + +### 17.4 Measured cost, and what did not fit + +| Artifact | Before | After | Headroom | +|---|---|---|---| +| `pulsenova` | 204,526 | **204,705** | 274 → **95** | +| `editorial` | 204,107 | **204,286** | 693 → **514** | +| `signature` | 203,938 | **204,117** | 862 → **683** | +| `notebook` | 203,764 | **203,943** | 1,036 → **857** | + +**+179 B**, on every artifact, because `flag.js` is in the shared app script (§9). +The ceiling was **not** raised; every artifact is inside 204,800 B. `pulsenova` +is the tightest at **95 B**, below the 258 B the Phase 1 block records and below +§10's ≥ 1 KB floor, which was already unmet on entry. + +### 17.5 The boundary that was NOT crossed + +Country **names**, **city names** and **transliterations** are not resolved, and +this is a deliberate stop rather than an oversight: + +1. **A country-name table does not fit.** `Turkey`/`Türkiye`/`Turkiye`, + `Russia`, `Finland` and ~250 others, in the spellings operators actually use, + is a table of well over 1 KB — an order of magnitude more than the 95 B of + headroom that remains. It cannot be added without removing bytes from + somewhere else or raising the ceiling, and both are out of scope for this + change. +2. **A city table is worse.** `Istanbul → TR` is deterministic; `London → GB or + UK` already is not; and the table grows without a natural bound. +3. **The rule for ambiguity is "refuse".** The task's own instruction — *do not + infer from ambiguous names* — and §5's fidelity rule both point the same way. + A wrong flag is a factual error; the monogram is merely plain. + +So `Turkey - Istanbul`, `Türkiye`, `Turkiye`, `Russia Moscow`, `United Kingdom +London` and `Finland Helsinki` still draw the monogram, and the tests **assert +that they do** — recording the boundary rather than pretending to cross it. Names +that carry an alpha-2 code or an emoji — including every reported real-world case +that had a code — now draw a flag. + +### 17.6 Locks and tests + +All **15** byte-locks were re-baselined (`tests/build.test.mjs`: the 4 individual +locks and the 13-row `FROZEN_ARTIFACTS` table), because `flag.js` is shared. +`tests/flag.test.mjs` gained six tests: the reported cases; **all 676 letter +pairs** against the 258-code registry; the uppercase/bounded/assigned refusals; +emoji-beats-code; a re-assertion that no previous answer moved; and the +country-name boundary. The previous 10 tests pass verbatim. + +--- + +ALPHA-2 INPUT PATH ADDED — RETURN SHAPE, `CODES` AND FALLBACKS UNCHANGED — 15 LOCKS RE-BASELINED diff --git a/docs/design/PASARGUARD-ADAPTER-AUDIT.md b/docs/design/PASARGUARD-ADAPTER-AUDIT.md index e0e59c7..d52235c 100644 --- a/docs/design/PASARGUARD-ADAPTER-AUDIT.md +++ b/docs/design/PASARGUARD-ADAPTER-AUDIT.md @@ -220,4 +220,59 @@ Everything else is DIRECT, ADAPTER, DERIVED, or a clean omission. --- -PASARGUARD ADAPTER AUDIT COMPLETE — NO ADAPTER CREATED — AWAITING REVIEW BEFORE PHASE 3B +## 8. 1.3.0 hardening addendum — the page context is not the /info payload + +*Added 2026-09-27, while hardening 1.3.0. This section corrects nothing above; it +records a distinction §4 did not need, because §4 is about the **adapter** and +this is about the **page**.* + +### 8.1 Two different PasarGuard surfaces + +The adapter and the page read **different** PasarGuard surfaces, and the same +field name can mean different things on each: + +| Surface | Where it comes from | What it holds | +|---|---|---| +| the `/info` **headers** | `create_response_headers()` in `app/operation/subscription.py` | the values the panel **resolved**: `support-url` = `admin.support_url or sub_settings.support_url`; `profile-title` = `encode_title(formatted_title)`, where `formatted_title` is the **admin's** `profile_title` or the panel-wide `Subscription.profile_title` (`"Subscription"` by default) | +| the **page context** | `_build_subscription_body_payload()` | `{user, links, announce, announce_url, apps}` — and **nothing else**. The Jinja2 environment adds only `now` (`app/templates/__init__.py`) | + +So §4's row for `title` (`profile-title` header, base64, DERIVED) is correct **for +the adapter**, and it is *not* what the page reads. The page cannot read a header +at all, and it cannot reach the panel-wide `Subscription` settings: they are not +in the context. What the page *can* read is the admin's own columns, because +`user.admin` is `AdminContactInfo` — `{id, username, telegram_id, +discord_webhook, sub_domain, profile_title, support_url, custom_variables, +notification_enable}`. + +### 8.2 The three fields this hardening needed + +| Needed | Page context source | Notes | +|---|---|---| +| subscriber name | `user.username` | The only deterministic subscriber identifier in the context. `email` does not exist on the model; `id`, `note`, `subscription_url` and `auto_delete_in_days` are `exclude=True`. `UserValidator.validate_username` bounds it to 3–128 characters of `[a-zA-Z0-9-_@.]`, so it is Latin and HTML-safe by the panel's own rule. | +| service / brand name | `user.admin.profile_title` | The admin's configured title — the same value that becomes the `profile-title` header when the admin's is the one chosen. The panel-wide `Subscription.profile_title` is **not reachable**, so the page's fallback is the subscriber's own name, which is the panel's own behaviour when nothing is configured. | +| support URL | `user.admin.support_url` | Already implemented and unchanged; the page reads the admin's column directly, which agrees with the header the panel resolves from it. | + +### 8.3 A profile title can carry format placeholders + +`setup_format_variables()` (§3) exists because a `profile_title` is +`str.format`-ed against those 13 variables before it is used. A page that shows +`user.admin.profile_title` verbatim can therefore render a literal +`{DATA_LIMIT}` or `{EXPIRE_DATE}`. The page cannot resolve them — the values are +in the panel's response, not the page context — so a title containing `{` is +refused and falls through to the subscriber's name. Showing the subscriber their +own name is better than showing them an unresolved placeholder. + +### 8.4 What this addendum does not change + +- The adapter's mapping in §4 is unchanged. **No adapter file is touched by the + 1.3.0 hardening**, and no PasarGuard field is newly consumed. +- §5's `Live polling via ?format=info` row stands as written: the page's poller + still uses 3X-UI's `?format=info` query, PasarGuard's endpoint is a path + suffix, and the failure is fail-safe (`unsupported`; the server-rendered + figures stand). It remains **deferred and out of scope**, as §5 and R7 say. +- R4 (`ip` must not be carried) is unchanged and still enforced: the page reads + `user.ip` nowhere. + +--- + +PASARGUARD ADAPTER AUDIT COMPLETE — ADAPTER WRITTEN IN 1.3.0 — §8 ADDED DURING 1.3.0 HARDENING diff --git a/docs/design/PGCLOCK-AUDIT.md b/docs/design/PGCLOCK-AUDIT.md new file mode 100644 index 0000000..773a9d3 --- /dev/null +++ b/docs/design/PGCLOCK-AUDIT.md @@ -0,0 +1,152 @@ +# PGClock — secondary-reference audit (1.3.0 hardening) + +**Status:** read-only audit. **No PGClock code was copied, ported or derived from.** +`PROVENANCE.md` is unchanged, and no licence obligation arises (see §5). + +**Subject:** , at `HEAD` = `2ee77a7` +("نسخهٔ استاندارد — قالب شیشه‌ای صفحهٔ اشتراک برای Pasarguard" — the standard +glass-style subscription page template for PasarGuard). + +**Question this audit answers:** does PGClock hold any PasarGuard behaviour that +Row-Template needs for the four 1.3.0 hardening items — subscriber identity, +service/brand name, support URL, or country/flag detection — and is any of it +worth importing? + +**Short answer: no.** PGClock is a single self-contained HTML page. It confirms +one decision Row had already taken from the panel source (`user.username` is the +subscriber identity), says nothing at all about the other three, and is +unlicensed, so nothing may be taken from it in any case. + +--- + +## 1. What the repository contains + +Every file at `HEAD`, from the Git tree API: + +| Path | Bytes | Kind | +|---|---|---| +| `README.md` | 2,920 | Persian install/usage notes | +| `install.sh` | — | Ubuntu installer + version menu (`Lite` / `PGClock` / `Pro`) | +| `index.html` | 91,075 | the page itself — CSS, markup and JS in one file | +| `Preview.png` | 288,721 | screenshot | +| `Host Version/index.html` | 94,525 | the "host" variant of the same page | +| `Host Version/index.php` | 1,862 | a thin wrapper | +| `Host Version/.htaccess` | 27 | rewrite rule | + +There is no `package.json`, no build step, no source directory and no test +suite. The README states this as a feature: *"یک فایل HTML — بدون Node.js و +build"* — one HTML file, no Node.js and no build. + +--- + +## 2. The install mechanism it uses (relevant, and consistent with Row) + +PGClock writes to `/var/lib/pasarguard/templates/subscription/index.html` and +sets two keys in `/opt/pasarguard/.env`: + +```env +CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/" +SUBSCRIPTION_PAGE_TEMPLATE="subscription/index.html" +``` + +That is the same mechanism Row-Template's PasarGuard installer uses +(`PASARGUARD-INSTALLER-AUDIT.md`), reached independently by a third party. It is +useful corroboration that the mechanism is the intended one — and it is the only +thing in the repository that is relevant to Row at all. Row's installer was +written from the panel source and is **not** changed by this finding. + +--- + +## 3. Subscriber identity — confirms `user.username`, and nothing more + +PGClock reads exactly one identity field, in one place: + +```js +var name = user.username || '—'; // index.html:2054 +document.title = name && name !== '—' ? name : 'اشتراک'; // index.html:2056 +``` + +So the page's own title is **the subscriber's username**, with the generic +Persian word for "subscription" (`اشتراک`) as the fallback — and the static +`اشتراک` in the markup is the same fallback. + +This is a **secondary, independent confirmation** of the decision Row's fix +takes from the panel source (`app/templates/subscription/index.html` titles +itself with `user.username`). It is confirmation only: the code is three lines +long, it is the obvious implementation, and nothing was taken from it. + +`user.username` is also the only field PGClock reads that Row's fix reads. Its +other reads — `status`, `used_traffic`, `data_limit`, `expire`, `online_at`, +`on_hold_expire_duration`, `on_hold_timeout`, `hwid_limit`, `created_at` — are +fields Row's adapter already maps, and none of them is an identity field. + +## 4. Service name, support URL and flags — absent + +The whole 91 KB file was searched, not sampled: + +| What was searched for | Occurrences in `index.html` | +|---|---| +| `user.username` | 1 (§3) | +| `profile_title` | **0** | +| `support_url` | **0** | +| `service_name` | **0** | +| `sub_settings` | **0** | +| `country` | **0** | +| `flag` | **0** | +| a regional-indicator code point, or `U+1F1E6`–`U+1F1FF` | **0** | + +The two apparent "ISO" hits are `Date.prototype.toISOString()` and a variable +named `isOpen`; there is no country-code logic of any kind. PGClock has **no +service/brand name variable, no support URL variable and no country or flag +mapping**, so it offers no guidance on hardening items 2 and 3. + +It is worth stating plainly *why* it has none, because it is the same reason Row +has none: PasarGuard's page context is +`{user, links, announce, announce_url, apps}` plus Jinja2's `now` +(`app/operation/subscription.py` `_build_subscription_body_payload`). A +`profile_title` or `support_url` is not in it. PGClock's author therefore had +nothing to read, exactly as Row does not — which is why Row's fix reads the +admin's own column (`user.admin.profile_title`, which *is* in the context) and +why the panel-wide settings are documented as unreachable +(`PASARGUARD-ADAPTER-AUDIT.md` §8). + +--- + +## 5. Licence — none, so nothing may be taken + +There is **no `LICENSE` file** (`raw.githubusercontent.com/.../LICENSE` → HTTP +404), **no licence header** in `index.html`, `install.sh`, `index.php` or +`.htaccess`, and **no licence section** in the README. The README's only +statement of terms is the install instructions. + +Under the default of "all rights reserved", PGClock is **not** open source, and +no code, comment, identifier, string or data table may be copied from it — +including the "obvious" three-line username block in §3, which Row did not copy +(it is written from the panel's own template, and the fix cites the panel file, +not PGClock). + +**No PGClock code is present in Row-Template.** This audit therefore does **not** +trigger the PROVENANCE obligation: `PROVENANCE.md` is unchanged, and no +attribution line is added. If PGClock is ever relicensed, or if any code is ever +taken from it, that file must be updated first. + +--- + +## 6. Verdict + +**PGClock is a useful secondary confirmation and a source of nothing else.** + +1. It independently corroborates `user.username` as the subscriber identity + (§3) — the same conclusion the panel source gives, reached without the + panel source. +2. It corroborates the `.env` + `CUSTOM_TEMPLATES_DIRECTORY` install mechanism + Row's PasarGuard installer already uses (§2). +3. It has no service-name, support-URL or flag logic to offer (§4). +4. It is unlicensed, so nothing could be taken from it even if it did (§5). + +No code was imported, no data table was transcribed, and no licence obligation +arises. The hardening items stand on the panel source, as the task requires. + +--- + +PGCLOCK AUDIT COMPLETE — READ-ONLY — NOTHING DERIVED — PROVENANCE UNCHANGED diff --git a/docs/design/README.md b/docs/design/README.md index 35f7b7c..fbcd6e0 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -42,6 +42,7 @@ is the only supported panel; PasarGuard and Rebecca are research targets. | [REBECCA-ADAPTER-AUDIT.md](REBECCA-ADAPTER-AUDIT.md) | Mapping Rebecca to the page's data contract, from source. | | [REBECCA-ADAPTER-DECISIONS.md](REBECCA-ADAPTER-DECISIONS.md) | Decision record for Rebecca's time and status values. | | [LIVE-POLLING-AUDIT.md](LIVE-POLLING-AUDIT.md) | Audit of the live status refresh contract across panels. | +| [PGCLOCK-AUDIT.md](PGCLOCK-AUDIT.md) | 1.3.0: read-only audit of the third-party PGClock template as a secondary reference. Nothing derived; `PROVENANCE.md` unchanged. | | [MULTIPANEL-CHECKPOINT-PLAN.md](MULTIPANEL-CHECKPOINT-PLAN.md) | Repository-state audit and commit plan for the multi-panel work. | ## Installer diff --git a/src/panels/pasarguard/prelude.jinja2 b/src/panels/pasarguard/prelude.jinja2 index 84597b5..9abfc81 100644 --- a/src/panels/pasarguard/prelude.jinja2 +++ b/src/panels/pasarguard/prelude.jinja2 @@ -22,9 +22,51 @@ lastOnline online_at in epoch milliseconds, empty when never seen subUrl user.subscription_url; empty makes the page use its own URL, which on PasarGuard IS the subscription URL + subTitle the name the page is identified by, from the two values the + panel puts in the page context: + user.admin.profile_title the operator's configured + subscription profile title + user.username the subscriber, when no profile + title is configured + See "THE PAGE TITLE" below for why these share one slot and + why the panel-wide SUB_PROFILE_TITLE cannot be read here. support the admin's support_url when the panel supplies one The subscriber's address (user.ip) is never read. + THE PAGE TITLE + + PasarGuard's own subscription page titles itself with the subscriber + (templates/subscription/index.html: `{{ user.username }} - Subscription + Information`), and the current official template + (PasarGuard/subscription-template, src/App.tsx) does the same: + `document.title = effectiveData.username`. Row's heading is the service name + instead (brand.js displayName: configured service name, then the panel's + title, then the localised word), so the panel's title is what has to carry + both meanings, and it carries them in this order: + + user.admin.profile_title -> user.username -> (the localised word) + + which is the panel's OWN order for a title it resolves itself + (app/operation/subscription.py _format_profile_title: the admin's + profile_title, then the panel-wide one), with the subscriber substituted for + the panel-wide default that the page context does not carry. + + WHY ONE SLOT. A separate subscriber field would be a fourteenth data-* + attribute, and the artifact it would live in has 274 bytes of headroom + against a 204,800-byte ceiling that may not be raised (tools/build.mjs). + Measured, that attribute plus its model and render paths costs ~157 B, and + the flag fix in this same change costs 179 B, so the two do not fit together. + The alternative — reading a missing Go map key — would also print the literal + "" into every 3X-UI page, whose view model has no such field and + which this change must not alter. + + A profile title is REFUSED when it still contains a `{`, because PasarGuard + formats those against variables the page cannot resolve + (app/subscription/share.py setup_format_variables: {USERNAME}, {DATA_LIMIT}, + {EXPIRE_DATE}, ...). Showing the operator a literal "{DATA_LIMIT}" is worse + than showing the subscriber's own name, so the placeholder form falls through + to the username. + Values are escaped on output by the autoescape block that follows this prelude: PasarGuard's environment does NOT autoescape by itself. ========================================================================== -#} @@ -48,11 +90,15 @@ {%- else -%} {%- set expire = 0 -%} {%- endif -%} +{%- set rt_admin = user.admin if user.admin else none -%} +{%- set rt_user = (user.username | string | trim) if user.username else '' -%} +{%- set rt_brand = (rt_admin.profile_title | string | trim) if rt_admin and rt_admin.profile_title else '' -%} +{%- if '{' in rt_brand -%}{%- set rt_brand = '' -%}{%- endif -%} {%- set subUrl = user.subscription_url or '' -%} {%- set subJsonUrl = '' -%} {%- set subClashUrl = '' -%} -{%- set subTitle = '' -%} -{%- set subSupportUrl = (user.admin.support_url or '') if user.admin else '' -%} +{%- set subTitle = rt_brand or rt_user -%} +{%- set subSupportUrl = (rt_admin.support_url | string | trim) if rt_admin and rt_admin.support_url else '' -%} {%- set datepicker = 'gregorian' -%} {%- set announce = announce or '' -%} {%- set links = links or [] -%} diff --git a/src/scripts/flag.js b/src/scripts/flag.js index 6ad7b95..9f908ad 100644 --- a/src/scripts/flag.js +++ b/src/scripts/flag.js @@ -84,6 +84,19 @@ export function paintFlag(badge, flag) { badge.style.color = 'transparent'; } +/* A node name often carries the country as an ISO 3166-1 alpha-2 code instead + of a flag emoji — "TR | Istanbul", "GB-LON-1", "DE". A two-letter run is read + as a code only when it is UPPERCASE and stands alone between non-letters, so + "LON" is not read as a code out of "GB-LON-1" and a lowercase English word + ("in", "no", "it") never matches at all. Membership is the SAME registry the + emoji path uses, so a code is accepted only when it names a real country, and + an unassigned pair is refused exactly as it is there. + + The token is only ever read, never removed from the label: a flag emoji means + a flag and nothing else, but two uppercase letters can be part of an + operator's own wording, so the name is left as written. */ +const CODE = /(?:^|[^A-Z])([A-Z]{2})(?![A-Z])/; + function isIndicator(cp) { return cp >= RI_FIRST && cp <= RI_LAST; } @@ -98,7 +111,8 @@ function letter(cp) { spurious code — "🇿🇿🇺🇸" reads past the tofu pair and finds US, it does not read the middle two as ZU. A lone trailing indicator matches nothing. */ export function flagOf(text) { - const points = Array.from(String(text === null || text === undefined ? '' : text), (ch) => ch.codePointAt(0)); + const s = String(text ?? ''); + const points = Array.from(s, (ch) => ch.codePointAt(0)); let i = 0; while (i < points.length) { if (isIndicator(points[i]) && i + 1 < points.length && isIndicator(points[i + 1])) { @@ -109,7 +123,10 @@ export function flagOf(text) { } i += 1; } - return ''; + const m = CODE.exec(s); + return m && CODES.has(m[1]) + ? String.fromCodePoint(RI_FIRST - 65 + m[1].charCodeAt(0), RI_FIRST - 65 + m[1].charCodeAt(1)) + : ''; } /* The same name with every regional indicator removed, so the flag is shown diff --git a/template/index.html b/template/index.html index a48ae20..a6c643d 100644 --- a/template/index.html +++ b/template/index.html @@ -2658,6 +2658,8 @@

badge.style.color = 'transparent'; } +const CODE = /(?:^|[^A-Z])([A-Z]{2})(?![A-Z])/; + function isIndicator(cp) { return cp >= RI_FIRST && cp <= RI_LAST; } @@ -2667,7 +2669,8 @@

} function flagOf(text) { - const points = Array.from(String(text === null || text === undefined ? '' : text), (ch) => ch.codePointAt(0)); + const s = String(text ?? ''); + const points = Array.from(s, (ch) => ch.codePointAt(0)); let i = 0; while (i < points.length) { if (isIndicator(points[i]) && i + 1 < points.length && isIndicator(points[i + 1])) { @@ -2678,7 +2681,10 @@

} i += 1; } - return ''; + const m = CODE.exec(s); + return m && CODES.has(m[1]) + ? String.fromCodePoint(RI_FIRST - 65 + m[1].charCodeAt(0), RI_FIRST - 65 + m[1].charCodeAt(1)) + : ''; } function cleanName(text) { diff --git a/tests/build.test.mjs b/tests/build.test.mjs index be5f647..d9da7f3 100644 --- a/tests/build.test.mjs +++ b/tests/build.test.mjs @@ -137,10 +137,10 @@ test('the default (Row) build is byte-locked to the v1.1.0 artifact', () => { const bytes = Buffer.byteLength(html, 'utf8'); const sha = createHash('sha256').update(html).digest('hex'); - assert.equal(bytes, 201258, 'Row artifact changed size; byte-lock violated'); + assert.equal(bytes, 201437, 'Row artifact changed size; byte-lock violated'); assert.equal( sha, - '9694504337c27dacae2b6e9e2b2954b03560ae44761f514ff7f051718ba66776', + 'bc04b5cbdaf6108c24518e2f1cbdf13e1881262a892184ccb752071c15c616fa', 'Row artifact changed content; byte-lock violated', ); @@ -241,11 +241,11 @@ test('the editorial layout satisfies the hook contract, shares the Row runtime, test('the editorial artifact is byte-locked to the approved magazine design', () => { const html = build(true, 'editorial').html; const bytes = Buffer.byteLength(html, 'utf8'); - assert.equal(bytes, 204107, 'Editorial artifact changed size; byte-lock violated'); + assert.equal(bytes, 204286, 'Editorial artifact changed size; byte-lock violated'); const sha = createHash('sha256').update(html).digest('hex'); assert.equal( sha, - '407b6008019975c3ce768db064bf9268795f2ccb24d3627e4040f2e9f502a8fd', + '56602f69dca5d87f1fd81c6610ba1460d2cc7e43738245eace87b7dfa0ebbfa6', 'Editorial artifact changed content; byte-lock violated', ); }); @@ -637,9 +637,9 @@ test('the saffron artifact names its own design, satisfies the hook contract, an test('the canvas artifact is byte-locked to the approved design', () => { const html = build(true, 'canvas').html; const bytes = Buffer.byteLength(html, 'utf8'); - assert.equal(bytes, 202598, 'Canvas artifact changed size; byte-lock violated'); + assert.equal(bytes, 202777, 'Canvas artifact changed size; byte-lock violated'); const sha = createHash('sha256').update(html).digest('hex'); - assert.equal(sha, '122ccfb1238a1265e7f1878ac862bb7ede8c7e6aff691f4599d043dcf1e3aefa', 'Canvas artifact changed content; byte-lock violated'); + assert.equal(sha, '967f53de2dd7d34ed83eb8478b5b9f4c291217958b3327d05956b144f4d7908d', 'Canvas artifact changed content; byte-lock violated'); }); /* Prism owns its layout: the faceted sheet is its own document, so the @@ -747,11 +747,11 @@ test('the pulsenova layout satisfies the hook contract, shares the Row runtime, test('the pulsenova artifact is byte-locked to the approved dashboard design', () => { const html = build(true, 'pulsenova').html; const bytes = Buffer.byteLength(html, 'utf8'); - assert.equal(bytes, 204526, 'Pulse Nova artifact changed size; byte-lock violated'); + assert.equal(bytes, 204705, 'Pulse Nova artifact changed size; byte-lock violated'); const sha = createHash('sha256').update(html).digest('hex'); assert.equal( sha, - 'c8c1f27201d717028b5994d02422f4d7fcb13ef60a856a556828969c39e0c6b2', + '3ec5f245c098a49b8506e3135eb65f5aa6759a412f3e94356cdc8fdab37c51ae', 'Pulse Nova artifact changed content; byte-lock violated', ); }); @@ -977,19 +977,19 @@ test('the arcadenova cabinet satisfies the hook contract, shares the Row runtime asserts both the artifact's exact byte length and its full SHA-256 through the same build() the CLI and the release generator use. */ const FROZEN_ARTIFACTS = [ - ['prism', 202386, 'dd6a45eb8ba2e706c521a9542032c39a95bce1d7b8444c5b9fec4c135d33134b'], - ['terminal', 199901, '75bd73accaddb403a7d657685f9a4d0d6b2e51da461a8efafe5a29b30ab6f4ba'], - ['pulse', 202334, 'f29c3ff07dcf9eaff2959607823439824f455d082e1771ff4371a8b00106ac7f'], - ['brutal', 202792, '6d676cdad52ff61c65b4a249093c6d6a2e9f8ac52efcff2efe84b176a4fa9731'], - ['arcade', 203292, '71e1141e427fa1de5d081ef580e6878c1d507c69ecd6bf186140b657f5904e53'], - ['sketch', 202334, '6ef86fc952a17632d9496141cdf9af57cca8bf31d9d39627af24347c9eb2a4d5'], - ['signature', 203938, '5d103d8956c25ce598148bf48797b83bf83c81a28b04729aaeae83af17381a9d'], - ['saffron', 203316, '78f20de84e635f0f2c75e7436537c5187f21e3067528c81c243e47e56ad99274'], - ['prismnova', 203226, 'c6eb485fdcf3fb66c5c705eb2f897b176c5fbc0c684b3ff624289714421708cd'], - ['terminalnova', 203203, 'afd6ed22a69450915f87fb7dcdfa7b2f8ee47da65f3896ae57ad73d2e00b587a'], - ['arcadenova', 203569, 'adb9b1088d8b3d492536cc883f53b806da54a5d2a2d749934fadf611964f450e'], - ['meter', 202571, '194ed2c361529fb0c399c36b724fe0b3053d6896e68d303b4a38429f4a872122'], - ['notebook', 203764, '5dd18cb6d7708e1b3ed50ab3186b84146c7a3b706567f2c6c45cfcb29034bda2'], + ['prism', 202565, 'dab4b9acaeed9985be5729f36140c428f6d8af09a2ae82952dc4c38830efb5cf'], + ['terminal', 200080, '158c106bb67c4818282054617a389c6ea5d52cf80c3b3a4fb00a7ca6aee7f30b'], + ['pulse', 202513, 'a83f39155da3ad1b08b189399a74c32e20d9327aace2e3a23b7273dc535ca695'], + ['brutal', 202971, '19b1d19aae5c3465d276c96af496b857c31ea3ecc2b29c6f7be55d437f2711eb'], + ['arcade', 203471, '61de63267902febe67f3f21c4a86fa3a40f759a2c2d2b94fa65d44f9f359e6de'], + ['sketch', 202513, '51b91f97cd5e6b92dfba0611aef3631e0f1623cfb3f6a7af24ea8a1400e6935a'], + ['signature', 204117, '0c21393dac6cd3356e43ef79e1ea9464eced175f3c98cd360cda420752096dc2'], + ['saffron', 203495, '3a178529c4ae37c9f3beac691ebbb7db4c3b4536c31d04e8efbbaa7efb737e85'], + ['prismnova', 203405, '873775a1c6ddecfd47c9a9b91a02afbd6a4a4f02da5b155d9fa7b60c19b0d91c'], + ['terminalnova', 203382, 'd84dca9c951a70fa3b079dc7bed001dd527f76fafc096c872504ca5221ca97a4'], + ['arcadenova', 203748, '4ff39bf9ff0ad6a52014298222037950d65bbe87e0f442051961d3bde626b92c'], + ['meter', 202750, '1796baa1466933b0380b69c846c2542562cd150ea129d3cde3f5b4f1944b85bc'], + ['notebook', 203943, 'cfcc6124673d2b2813f37e8ccfc287a46c0e70db6e3932f044c2ee58cf2de927'], ]; for (const [id, bytes, sha] of FROZEN_ARTIFACTS) { diff --git a/tests/flag.test.mjs b/tests/flag.test.mjs index 130ee62..97c36f7 100644 --- a/tests/flag.test.mjs +++ b/tests/flag.test.mjs @@ -60,6 +60,129 @@ test('a name with no flag is left as it is', () => { assert.equal(cleanName(null), ''); }); +/* --- ISO alpha-2 codes ----------------------------------------------------- + A node name often carries the country as a code rather than a flag emoji. + The badge is the same badge the emoji path draws: the pair is built from the + code, and membership is the same 258-code registry, so a code is accepted + only when it names a real country. The label is never rewritten — two + uppercase letters can be an operator's own wording, and only an emoji + unambiguously means a flag. */ + +/* The two regional indicators for an alpha-2 code, built from the block + offset, so the expectation is not a hand-copied emoji. */ +const pair = (code) => String.fromCodePoint( + 0x1f1e6 + code.charCodeAt(0) - 65, + 0x1f1e6 + code.charCodeAt(1) - 65, +); + +test('a node name carrying an ISO alpha-2 code gets that country flag', () => { + const cases = [ + ['TR | Istanbul', 'TR'], + ['TR-01', 'TR'], + ['TR_01', 'TR'], + ['RU-01', 'RU'], + ['GB-LON-1', 'GB'], + /* "UK" is the exceptionally reserved code the registry already carries + beside GB, because its flag is widely rendered (see src/scripts/flag.js). + The code path therefore accepts it, and "UK London" and "GB-LON-1" + resolve to the two different-but-both-real UK flag emoji. */ + ['UK London', 'UK'], + ['FI-01', 'FI'], + ['DE', 'DE'], + ['NL | Amsterdam', 'NL'], + ['US East', 'US'], + ['FR-Paris-2', 'FR'], + ['AE Dubai', 'AE'], + ['IR Tehran', 'IR'], + ['CA Toronto', 'CA'], + ['JP Tokyo', 'JP'], + ['SG', 'SG'], + ['CH Zurich', 'CH'], + ['SE Stockholm', 'SE'], + ['NO Oslo', 'NO'], + ['ES Madrid', 'ES'], + ['IT Milano', 'IT'], + ['PL Warsaw', 'PL'], + ]; + for (const [raw, code] of cases) { + assert.equal(flagOf(raw), pair(code), `${raw} -> ${code}`); + } +}); + +test('every assigned code resolves through the code path, and no other pair does', () => { + /* The whole registry, so the code path can never be narrower than the emoji + path it stands beside. */ + const assigned = new Set(EXPECTED_CODES); + let found = 0; + for (let a = 0; a < 26; a += 1) { + for (let b = 0; b < 26; b += 1) { + const code = String.fromCharCode(65 + a) + String.fromCharCode(65 + b); + const got = flagOf(`Node ${code}`); + assert.equal(got, assigned.has(code) ? pair(code) : '', code); + if (got) found += 1; + } + } + assert.equal(found, 258, 'exactly the assigned codes'); +}); + +test('a code is read only when it is uppercase and stands alone', () => { + /* The ambiguous cases. A lowercase run is never a code, so the English words + that happen to be assigned codes cannot be read as countries. */ + for (const raw of [ + 'no flag here', 'it support', 'us east', 'in the cloud', 'my server', + 'amsterdam', 'is it up', 'at the edge', 'do not use', 'so slow', 'be quick', + ]) { + assert.equal(flagOf(raw), '', `${raw} must not be read as a country`); + } + /* Three or more letters are not a code, and neither is a code inside a word. */ + assert.equal(flagOf('LON'), '', 'a three-letter run is not a code'); + assert.equal(flagOf('GB-LON-1'), pair('GB'), 'and does not stop the real code being found'); + assert.equal(flagOf('USA'), '', 'a code inside a longer word is not read'); + assert.equal(flagOf('A1B'), '', 'a letter either side disqualifies it'); + assert.equal(flagOf('ZZ-01'), '', 'an unassigned pair is refused, exactly as in the emoji path'); + assert.equal(flagOf('QQ'), ''); + assert.equal(flagOf('XX'), ''); +}); + +test('a flag emoji still wins over a code in the same name', () => { + assert.equal(flagOf('\u{1F1E9}\u{1F1EA} TR-01'), '\u{1F1E9}\u{1F1EA}', 'the emoji is the more explicit claim'); + assert.equal(flagOf('TR \u{1F1E9}\u{1F1EA}'), '\u{1F1E9}\u{1F1EA}'); +}); + +test('the code path changes no existing answer', () => { + /* The whole previous behaviour, re-asserted through the same entry point. */ + assert.equal(flagOf('No flag here'), ''); + assert.equal(flagOf('\u{1F525} Fast'), ''); + assert.equal(flagOf('\u{1F3F3}\u{FE0F} white'), ''); + assert.equal(flagOf(''), ''); + assert.equal(flagOf(null), ''); + assert.equal(flagOf(undefined), ''); + assert.equal(flagOf(42), ''); + assert.equal(flagOf('\u{1F1FF}\u{1F1FF}\u{1F1FA}\u{1F1F8}'), '\u{1F1FA}\u{1F1F8}'); +}); + +test('the label is left exactly as the operator wrote it', () => { + /* A code is not stripped from the name: only an emoji unambiguously means a + flag, and "TR" may be part of a name rather than a country. */ + assert.equal(cleanName('TR | Istanbul'), 'TR | Istanbul'); + assert.equal(cleanName('GB-LON-1'), 'GB-LON-1'); + assert.equal(cleanName('DE'), 'DE'); + assert.equal(cleanName('Node TR'), 'Node TR'); +}); + +test('a country name that is not a code is still refused, not guessed', () => { + /* Reported real-world names that carry neither an emoji nor a code. The + monogram fallback is the honest answer for these until a country-name + table exists; this test records the boundary rather than pretending to + cross it. */ + for (const raw of [ + 'Turkey - Istanbul', 'T\u00FCrkiye', 'Turkiye', 'Russia Moscow', + 'United Kingdom London', 'Finland Helsinki', + ]) { + assert.equal(flagOf(raw), '', `${raw}: no code, no flag`); + } +}); + /* The full slate of names the refinement calls out: a flag leads, trails or sits mid-name; the label is Latin, Persian, Turkish, Chinese, Cyrillic; and the generic emoji that are not countries draw no badge and are left in place. */ diff --git a/tests/panels-engines.test.mjs b/tests/panels-engines.test.mjs index f6b0bc7..3703d17 100644 --- a/tests/panels-engines.test.mjs +++ b/tests/panels-engines.test.mjs @@ -24,7 +24,7 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { assembleShell, wrapForPanel, TEMPLATE_DELIMITER } from '../tools/shell.mjs'; -import { extractIsland, toModel } from '../tools/contract.mjs'; +import { extractIsland, toModel, validateIsland } from '../tools/contract.mjs'; import { templateIds } from '../tools/templates.mjs'; import { renderPasarGuard, renderRebecca, pasarguardContext, rebeccaContext, @@ -58,13 +58,26 @@ const HOSTILE = '">{{ 7*7 } function pgExpected(doc, links) { return { ...doc.expected.model, - title: '', // not in PasarGuard's page context + title: pgPageTitle(doc), subUrl: '', // not a database column: the page uses its own URL subClashUrl: '', links, }; } +/* The page's title, as the shipped prelude derives it + (src/panels/pasarguard/prelude.jinja2): the admin's configured profile title + when there is one, otherwise the subscriber's own name. A profile title that + still carries a `{` is refused, because PasarGuard formats those against + variables the page cannot resolve, and a literal "{DATA_LIMIT}" on the page + is worse than the subscriber's name. */ +function pgPageTitle(doc) { + const admin = doc.native.admin || null; + const brand = admin && admin.profile_title ? String(admin.profile_title).trim() : ''; + if (brand !== '' && !brand.includes('{')) return brand; + return String(doc.native.info.username ?? '').trim(); +} + function rbExpected(doc, links) { const subUrl = doc.native.info.subscription_url || ''; return { @@ -329,3 +342,125 @@ test('the shipped PasarGuard shell escapes for itself: the body sits in an autoe assert.equal(html.slice(close).includes('{{'), false, `${id}: nothing is interpolated after the block closes`); } }); + +/* --- the subscriber and the service name ------------------------------------ + The page context carries the subscriber (user.username) and the admin's own + columns (user.admin.profile_title, user.admin.support_url). It does NOT carry + the panel-wide subscription settings, so those cannot reach the page at all. + The prelude resolves the page's name in the panel's own order — the admin's + configured profile title, then the subscriber — and refuses a profile title + that still carries one of the panel's format placeholders. Every case below + runs through the SHIPPED prelude on the real Jinja2. */ + +/* The page context a real install builds for one subscriber, with the two + values this section is about supplied explicitly. */ +function pgPage({ username, profileTitle = null, supportUrl = null, withAdmin = true }) { + const doc = PG.find((d) => d.case === '01-active-online'); + const context = pasarguardContext(doc); + context.user.username = username; + context.user.admin = withAdmin + ? { support_url: supportUrl, profile_title: profileTitle } + : null; + const [html] = renderPasarGuard([{ html: SHELL.pasarguard, context }]); + return { html, model: toModel(extractIsland(html)) }; +} + +test('PasarGuard: with no configured profile title the page names the subscriber', () => { + /* The reported defect: the page titled itself with the generic word. */ + const { html, model } = pgPage({ username: 'alice' }); + assert.equal(model.title, 'alice', 'the subscriber reaches the page model'); + assert.ok(html.includes('alice'), 'and names the browser title'); + assert.equal(html.includes('Subscription'), false, 'never the generic word'); +}); + +test('PasarGuard: the admin\'s configured profile title names the page, over the subscriber', () => { + const { html, model } = pgPage({ username: 'alice', profileTitle: 'Premium 100 GB' }); + assert.equal(model.title, 'Premium 100 GB', 'the configured service name wins'); + assert.ok(html.includes('Premium 100 GB'), 'and reaches the page'); +}); + +test('PasarGuard: a profile title carrying a format placeholder is refused, not shown literally', () => { + /* PasarGuard formats these against variables the page cannot resolve + (setup_format_variables). A literal "{DATA_LIMIT}" on the page would be a + worse defect than the subscriber's own name. */ + const titles = ['MyNet {USERNAME}', '{DATA_LIMIT} left', '{EXPIRE_DATE}', '{SERVER_IP}']; + const pages = renderPasarGuard(titles.map((t) => { + const doc = PG.find((d) => d.case === '01-active-online'); + const context = pasarguardContext(doc); + context.user.username = 'alice'; + context.user.admin = { support_url: null, profile_title: t }; + return { html: SHELL.pasarguard, context }; + })); + pages.forEach((html, i) => { + assert.equal(toModel(extractIsland(html)).title, 'alice', `${titles[i]}: falls through to the subscriber`); + assert.equal(html.includes(titles[i]), false, `${titles[i]}: never reaches the page`); + /* The placeholder must not survive in any form: the island's title is the + subscriber, and no rendered attribute carries a brace pair. */ + assert.equal(html.includes('data-sub-title="alice"'), true, `${titles[i]}: the island names the subscriber`); + assert.equal(/data-sub-title="[^"]*\{/.test(html), false, `${titles[i]}: no placeholder in the island title`); + for (const v of ['{USERNAME}', '{DATA_LIMIT}', '{EXPIRE_DATE}', '{SERVER_IP}']) { + assert.equal(html.includes(v), false, `${titles[i]}: ${v} is not on the page`); + } + }); +}); + +test('PasarGuard: a missing subscriber name keeps the generic fallback', () => { + for (const value of [null, undefined, '', ' ']) { + const { html, model } = pgPage({ username: value }); + const label = JSON.stringify(value); + assert.equal(model.title, '', `${label}: no name is invented`); + assert.ok(html.includes('Subscription'), `${label}: the generic fallback stands`); + } +}); + +test('PasarGuard: a subscriber with no admin at all is still named', () => { + const { model } = pgPage({ username: 'alice', withAdmin: false }); + assert.equal(model.title, 'alice'); +}); + +test('PasarGuard: the subscriber name is escaped and never becomes markup', () => { + const { html, model } = pgPage({ username: HOSTILE, profileTitle: HOSTILE }); + assert.equal(model.title, HOSTILE, 'the name arrives whole, as text'); + assert.equal(html.includes(HOSTILE), false, 'and never appears unescaped'); + assert.equal(html.includes('">