diff --git a/CHANGELOG.md b/CHANGELOG.md index 603bb4c..4459b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,240 @@ 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). -## [1.2.0] - Unreleased +## [1.3.0] - 2026-09-26 + +Row-Template now installs on **PasarGuard** and **Rebecca** as well as 3X-UI, +and ships two more designs. A minor release: nothing changes for an existing +3X-UI install except what is listed below, and Row stays the default design. +It also carries every fix prepared for 1.2.1, which was not released on its +own. + +### Added + +- **PasarGuard support.** PasarGuard is supported from this release: detect, + install, activate, verify, back up, restore and uninstall, on the official + Docker install and on a source install (`pasarguard.service`). The page is + placed at `/var/lib/pasarguard/templates/row-template/index.html` (or inside + your own `CUSTOM_TEMPLATES_DIRECTORY`) and selected by one marked block + appended to `/opt/pasarguard/.env`; a running panel is restarted once. None + of your own `.env` lines is edited, and uninstall returns the file to its + exact previous bytes. `row-template verify` also reports the two panel + settings that still take precedence over the page: an admin's own + `sub_template`, and `disable_sub_template`. +- **Rebecca support.** Rebecca 1.x — the Go edition, which Rebecca publishes for + its binary install — is supported from this release, with the same seven + operations. The page is placed at + `/var/lib/rebecca/templates/row-template/index.html` (or inside your own + custom templates directory) and selected in the newest + `subscription_settings` row, which Rebecca reads on every request — so + nothing is ever restarted. Activation is automatic with the default SQLite + database and `sqlite3`; with MySQL/MariaDB the page is still placed and the + installer prints the two values to enter in the dashboard. `NULL`, empty and + a set templates directory are each restored exactly. +- **Panel detection and choice.** The installer finds the panel on the server + and installs for it (`/etc/3x-ui/sub_templates/row-template` for 3X-UI, + `/etc/row-template` for PasarGuard and Rebecca). A panel counts only when two + independent signals agree; a half-installed panel is refused, not guessed at. + On a server with more than one panel it asks, or reads + `RT_PANEL=3xui|pasarguard|rebecca` in a script. +- **Transactional activation on PasarGuard and Rebecca.** The panel's state is + snapshotted, changed and verified; if any step fails it is restored exactly, + and the installer says so — and shows the real cause. +- **Two new designs: Meter and Notebook.** Meter is a calm instrument + dashboard of rounded cards with a segmented traffic meter; Notebook is a + page from a dotted notebook, hand-inked. Both were contributed by the + project's author, ported onto the shared runtime, and held to the same + contract as the other fifteen — seventeen designs in all, on every panel. +- **Every design, for every panel.** Each release now carries a PasarGuard + (Jinja2) and a Rebecca (pongo2) page for every design, under `shells/`, + checksum-verified like the 3X-UI pages. + +### Fixed + +- **Rolling back to a backup taken under 1.1.0 works.** 1.2.x refused it with + "backup artifact matches no installed template". A backup that names its + design is restored as that design; one whose page is none of this release's + designs (1.1.0's) is restored as this release's Row, so `verify`, design + switching and updates keep working afterwards. +- **A successful rollback is reported as a success.** The transaction engine + checked, after restoring the panel, that the panel was still pointing at + Row-Template's directory — which is exactly the state a correct rollback has + just undone. Every rollback therefore ended in "the rollback failed" even + when the panel had been restored perfectly. The engine no longer asks that + question: the restore verifies itself. Each panel adapter now re-reads the + panel's own setting after restoring and confirms it matches the value it + recorded before changing anything, and a restore that does not land is + reported as a failed rollback with the real cause. A regression test pins + this: the engine must never re-run the forward check after a restore. +- **The manual PasarGuard instructions are complete.** When activation cannot + be done automatically, the installer printed only `SUBSCRIPTION_PAGE_TEMPLATE` + and told you to edit `.env` — but the page had not been copied anywhere the + panel could read. It now prints both the copy and the two `.env` values + (`CUSTOM_TEMPLATES_DIRECTORY` and `SUBSCRIPTION_PAGE_TEMPLATE`), and says to + keep your own templates directory if you already have one. +- **A rollback right after a change undoes that change.** Backup names have + one-second resolution, and two backups made in the same second — a design + switch followed at once by `row-template rollback --auto`, which snapshots + the current state first — shared one directory. The newer snapshot + overwrote the older one, so the rollback re-applied the state it was meant + to undo. A backup now waits for the next second rather than reuse a name. +- **The live check after `config`, `update` and `rollback` runs on 3X-UI.** + It always said "skipped (no test URL available without sqlite3)", even with + `sqlite3` installed, because those commands had not located the panel + database. And the check made right after activation no longer warns "could + not reach the subscription endpoint" while 3X-UI is still restarting. +- **A page change that cannot reach PasarGuard or Rebecca changes nothing.** + Regenerating the page (a rebrand, a design switch, an update) replaced + `sub.html` before copying it into the panel; if that copy failed, `sub.html` + was left newer than the page the panel serves. It is now put back. +- **A valid page is never refused under load.** The structural check before + every install, update and design switch read the page through + `head | grep -q`. On a busy server `grep -q` could stop reading before `head` + finished writing, and the shell then reported the match as a failure — + "generated template does not begin with " for a perfectly + valid page, about once in 150 checks. Every such check is now written so + that it cannot be cut short. +- All fixes prepared for 1.2.1 (below): one `row-template update` is enough to + move from 1.1.0, misplaced designs are moved back, branding works on an + install the 1.1.0 updater left incomplete, and `verify` names missing and + damaged designs. + +### Security + +- **Every value is escaped on every panel.** PasarGuard renders pages with a + non-sandboxed Jinja2 whose autoescaping is off. Every PasarGuard and Rebecca + page therefore wraps its body in an explicit autoescape block, and is tested + with the panels' real engines against hostile usernames, notes, links and + malformed data. +- **Branding can never open a template tag.** `{` and `}` in your service name, + support link or logo are written as `{` and `}`, so no branding + value can start a Jinja2 or pongo2 expression. +- **Panel secrets stay where they are.** PasarGuard's `.env` and Rebecca's + database URL are read only for the keys the installer needs, never printed, + and never copied into a backup. A MySQL/MariaDB password is never asked for + or read. +- **Backups record their panel** and are never restored onto another one. + +### Changed + +- `row-template version` shows the panel it serves; on 3X-UI it still shows the + minimum-supported and detected versions. +- `row-template uninstall` returns each panel to the page it had before + Row-Template, and leaves a page you chose afterwards alone. +- The `on_hold` state on PasarGuard and Rebecca is shown as active: with its + "starts on first connection" duration on PasarGuard, and with an unknown + expiry on Rebecca, which does not give the page that duration + (`docs/design/PANEL-ON-HOLD-DECISION.md`). + +### Known limitations + +- On PasarGuard and Rebecca the page shows the values as of when it was opened; + live refresh (`?format=info`) is 3X-UI only, because both panels serve live + status on a path suffix. +- PasarGuard's page title (`subTitle`) and Clash templates are not produced. +- Rebecca on MySQL/MariaDB needs its one setting entered in the dashboard. +- Rebecca's Docker image (`rebeccapanel/rebecca` on Docker Hub) is still the + 0.0.x Python edition, which cannot render this page. The installer + identifies it and refuses before changing anything; Rebecca's own + `rebecca migrate-binary` moves a Docker install to 1.x. + +### Documentation + +- The compatibility page, installation, configuration and troubleshooting + cover all three panels, in English, Persian and Arabic; the READMEs in all + five languages describe PasarGuard and Rebecca as supported. +- `docs/design/PASARGUARD-INSTALLER-AUDIT.md` and + `docs/design/REBECCA-INSTALLER-AUDIT.md` record, from each panel's source, + what activation is and how the installer follows it. + +### Development + +- The test suite renders the PasarGuard and Rebecca pages with the real + engines, and needs Python 3 with Jinja2 as well as Go; a missing engine is a + failure, never a skip. +- `tools/make-release.sh` writes checksums in the text form on every platform. + +### Upgrading + +- From **1.2.0** or **1.1.0** on 3X-UI: run `row-template update`. From 1.1.0, + the next `row-template`, `row-template config` or `row-template verify` + completes the install. Your design, branding and panel wiring are kept. +- On **PasarGuard** or **Rebecca**: run the installer. Earlier releases did not + install on these panels. Rebecca must be 1.x (its binary install); a Docker + Rebecca is 0.0.x and is refused until it is moved to 1.x. +- **Rolling back after the update.** `row-template update` backs up the version + it replaces, and `row-template rollback --to ` returns to its page + and branding. A rollback restores the page and the recorded version, not the + manager itself: `row-template` stays 1.3.0 and reports the version it rolled + back to, and the next `row-template update` returns to 1.3.0. Only the two + newest backups are kept, so the pre-update backup is replaced after two + further changes (a design switch, an update or a rollback each make one). + +## [1.2.1] - Unreleased (shipped in 1.3.0) + +Fixes the update from 1.1.0, which could leave the manager with no designs to +choose from. 3X-UI (>= 3.6.0) stays the only supported panel. + +### Fixed + +- **One `row-template update` is enough to move from 1.1.0.** 1.1.0's own + updater installs the new version but copies only four files, so in 1.2.0 the + designs were missing until a second update, and **Reconfigure branding → + Template** said "No templates are installed". Now the first time you open + `row-template`, or run `row-template config` or `row-template verify` as + root, after the update, it downloads the rest of the same release — every + design and the remaining installer files, checksum-verified — before doing + anything else. It downloads the version you have installed, never a newer + one, and changes nothing else: the live page, branding, selected design and + backups stay as they are. If the release cannot be reached, it says so and + tries again the next time the manager opens. +- **Designs found outside their folder are moved back.** The designs belong in + `dist/templates/`. A copy at the install root's `templates/` — where a copied + or extracted release leaves it — is now moved into place automatically by + `install`, `update` and `verify`. Each design is checked against its own + checksum first; one that fails is reported and left where it is, and files + Row-Template does not recognise are never removed. +- **Changing branding works on an install the 1.1.0 updater left incomplete.** + `row-template config` and the manager's branding editors refused with "the + template selection could not be reconciled" until a second update; they now + complete the install first. +- **`row-template verify` names missing and damaged designs.** A design that + fails its checksum is reported by name as a failure; missing designs are a + warning that names them. It previously reported a failing store without + saying which design, and did not report missing ones at all. + +### Changed + +- `row-template verify` is no longer strictly read-only. Run as root, it first + repairs the template store — moving misplaced designs back into place and + downloading any the installed version is missing, from that same release — + and then checks it. It makes no other change, and none at all when run + without root. + +### Documentation + +- The compatibility page lists, per panel, what the installer can do today: + detection, install, activation, verification, and backup and rollback. For + PasarGuard and Rebecca the answer is none of them — only the page shells are + built and packaged — so both stay **research targets, not supported panels**. + A test checks every README and compatibility page against the installer. + +### Known issues + +- Rolling back from 1.2.x to a backup taken under 1.1.0 fails with "backup + artifact matches no installed template": 1.1.0's page is not one of the + current release's designs. The rollback stops before changing anything, so + the running page stays as it was. Rolling back to a backup taken under 1.2.x + is not affected. + +### Upgrading + +- From **1.1.0**: run `row-template update`. The next `row-template`, + `row-template config` or `row-template verify` completes the install. +- From **1.2.0**: run `row-template update`. This also completes a 1.2.0 + install that the 1.1.0 updater left without its designs. + +## [1.2.0] - 2026-09-24 Turns Row-Template from one page into a collection of designs. A minor release: Row stays the default design, and 3X-UI (>= 3.6.0) stays the only supported @@ -84,7 +317,8 @@ panel. page updates and your branding is kept — but copies only the library and the command, so only Row is available. The second, carried out by 1.2.0, installs every design and the remaining installer files. `row-template - verify` reports whether the second run is still needed. + verify` reports whether the second run is still needed. (Fixed in 1.2.1, + which needs one run.) ## [1.1.0] - 2026-08-30 @@ -162,6 +396,8 @@ First stable release. - Requires 3X-UI (MHSanaei) **>= 3.6.0**; validated against stock 3.7.0. - Recommended operating system: Ubuntu 24.04 LTS (x86_64). -[1.2.0]: https://github.com/iitzSeriZdev/Row-Template/compare/v1.1.0...main +[1.3.0]: https://github.com/iitzSeriZdev/Row-Template/releases/tag/v1.3.0 +[1.2.1]: https://github.com/iitzSeriZdev/Row-Template/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/iitzSeriZdev/Row-Template/releases/tag/v1.2.0 [1.1.0]: https://github.com/iitzSeriZdev/Row-Template/releases/tag/v1.1.0 [1.0.0]: https://github.com/iitzSeriZdev/Row-Template/releases/tag/v1.0.0 diff --git a/PROVENANCE.md b/PROVENANCE.md index 58d6a7a..532c405 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -13,7 +13,7 @@ Every release published on GitHub carries these assets: | ----- | ------- | | `row-template-.tar.gz` | The runtime payload — see below. | | `SHA256SUMS` | The SHA-256 checksum of the tarball above. | -| `manifest.txt` | Plain-text metadata (`name`, `version`, `artifact`, `min_xui`, `created`), parsed as data — never executed. | +| `manifest.txt` | Plain-text metadata (`name`, `version`, `artifact`, `min_xui`, `created`), parsed as data — never executed. `min_xui` applies to 3X-UI only. | | `install.sh` | The bootstrap used by the one-command installer. | The tarball expands to a single `row-template-/` directory: @@ -21,12 +21,21 @@ The tarball expands to a single `row-template-/` directory: | Path | Contents | | ---- | -------- | | `template.html` | The Row design, the page an older installed version updates against. | -| `templates//template.html` (+ `.sha256`) | Every selectable design, each with its own checksum. | -| `shells///shell.html` (+ `.sha256`) | Each design's page shell per panel, packaged for research; the installer does not place them. | +| `templates//template.html` (+ `.sha256`) | Every selectable design for 3X-UI, each with its own checksum. | +| `shells///shell.html` (+ `.sha256`) | Every design for every panel, each with its own checksum. On PasarGuard (Jinja2) and Rebecca 1.x (pongo2) the installer places the one you select, and refuses a page built for another panel or by a release before 1.3.0. `shells/3xui/` is byte-identical to `templates/`, which is what 3X-UI installs use. | | `VERSION`, `install.sh`, `lib/`, `bin/` | The version, the installer and the `row-template` manager. | -| `panels/` | The panel interface layer the manager loads; installed next to `lib/`. | +| `panels/` | The panel interface and one adapter per panel (`3xui.sh`, `pasarguard.sh`, `rebecca.sh`); installed next to `lib/`. | | `SHA256SUMS` | The checksum of every payload file, so the contents can be checked after extraction as well. | +Every design is built from this repository's own sources (`src/`). Meter and +Notebook (1.3.0) were contributed by the project's author and ported onto the +shared runtime; like every other design they contain no third-party code beyond +the bundled QR generator and font listed in the README's License section. The +PasarGuard and Rebecca pages contain no code from either panel: both panels are +AGPL-3.0, so the preludes and the test harnesses that render them with the +panels' real engines are independent implementations, written from the source +audits in `docs/design/`. + The build is deterministic: the same sources always produce a byte-identical `row-template-.tar.gz`. Anyone can rebuild it from a checkout with `tools/make-release.sh` (which needs Node.js to build the designs) and compare diff --git a/README.ar.md b/README.ar.md index 82f6533..7964c42 100644 --- a/README.ar.md +++ b/README.ar.md @@ -6,7 +6,7 @@

- صفحة اشتراك مصقولة ومكتفية ذاتيًا للوحات 3X-UI — خمسة عشر تصميمًا، كلٌّ منها ملف HTML واحد، قابلة لإعادة التسمية بالكامل (white-label)، ودون أي طلبات إلى أطراف ثالثة من الصفحة التي يفتحها مشتركوك. + صفحة اشتراك مصقولة ومكتفية ذاتيًا للوحات 3X-UI وPasarGuard وRebecca — سبعة عشر تصميمًا، كلٌّ منها ملف HTML واحد، قابلة لإعادة التسمية بالكامل (white-label)، ودون أي طلبات إلى أطراف ثالثة من الصفحة التي يفتحها مشتركوك.

@@ -16,7 +16,7 @@

License Latest release - Panel + Panels Platform Documentation

@@ -33,21 +33,21 @@ ## ما هو Row-Template؟ -يستطيع 3X-UI أن يعرض على المشتركين صفحة مخصّصة بدلًا من صفحته المدمجة. وRow-Template هو تلك الصفحة: يفتح المشترك رابط اشتراكه فيرى باقته واستهلاكه وتاريخ انتهاء اشتراكه، مع طرق لإضافة الاشتراك بلمسة واحدة إلى التطبيق الذي يستخدمه. +تستطيع كلٌّ من 3X-UI وPasarGuard وRebecca أن تعرض على المشتركين صفحة مخصّصة بدلًا من صفحتها المدمجة. وRow-Template هو تلك الصفحة: يفتح المشترك رابط اشتراكه فيرى باقته واستهلاكه وتاريخ انتهاء اشتراكه، مع طرق لإضافة الاشتراك بلمسة واحدة إلى التطبيق الذي يستخدمه. -يُقدَّم كل تصميم في ملف HTML واحد مكتفٍ ذاتيًا، تُضمَّن فيه جميع الأنماط والسكربتات والخطوط ومولّد رمز QR. يثبّته أمر واحد بجوار لوحتك، ويوجّه اللوحة إليه، ويمنحك المدير `row-template` لإدارة العلامة التجارية والتحديثات والتراجع. +يُقدَّم كل تصميم في ملف HTML واحد مكتفٍ ذاتيًا، تُضمَّن فيه جميع الأنماط والسكربتات والخطوط ومولّد رمز QR، ومعه نسخة من كل تصميم بلغة قوالب كل لوحة. يكتشف أمر واحد لوحتك، ويثبّت الصفحة بجوارها، ويوجّه اللوحة إليها، ويمنحك المدير `row-template` لإدارة العلامة التجارية والتحديثات والتراجع. ## لماذا Row-Template؟ - **الخصوصية في صميم التصميم.** الصفحة التي يفتحها مشتركوك لا ترسل أي طلبات إلى أطراف ثالثة. تُولَّد رموز QR داخل الصفحة نفسها، وتُحقن علامتك التجارية كنص — لا تُنفَّذ أبدًا ولا تُرسل إلى أي مكان. - **إعادة تسمية حقيقية بالكامل.** اسم خدمتك، ورابط الدعم الخاص بك، وشعارك. لا شيء في الصفحة المعروضة يشير إلى Row-Template. -- **خمسة عشر تصميمًا، كلٌّ في ملف واحد.** اختر المظهر الذي يناسب خدمتك. جميع التصاميم تتشارك المزايا واللغات وفحوص الأمان نفسها. +- **سبعة عشر تصميمًا، كلٌّ في ملف واحد.** اختر المظهر الذي يناسب خدمتك. جميع التصاميم تتشارك المزايا واللغات وفحوص الأمان نفسها — على كل لوحة مدعومة. - **مصمَّم لمشتركيك.** عرض حيّ للاستهلاك وتاريخ الانتهاء، واستيراد بلمسة واحدة إلى التطبيقات الشائعة، وقائمة قابلة للبحث بالإعدادات الفردية لإضافة خادم واحد يدويًا. -- **آمن في التشغيل.** إصدارات يُتحقَّق من مجموعها الاختباري، وتفعيل ذرّي، وتراجع بأمر واحد. لا يعدّل 3X-UI أبدًا: الإعداد الوحيد الذي يغيّره في اللوحة هو مجلد صفحة الاشتراك (`subThemeDir`). +- **آمن في التشغيل.** إصدارات يُتحقَّق من مجموعها الاختباري، وتفعيل على هيئة معاملة يعيد اللوحة إلى حالتها بدقة إن فشلت أي خطوة، وتراجع بأمر واحد. لا يعدّل لوحتك أبدًا: في 3X-UI يغيّر إعدادًا واحدًا (`subThemeDir`)، وفي PasarGuard يضيف كتلة معلَّمة واحدة إلى `.env`، وفي Rebecca يضبط حقلين من إعدادات الاشتراك. ## التصاميم -يأتي Row-Template 1.2.0 بخمسة عشر تصميمًا، والتصميم الافتراضي هو Row. +يأتي Row-Template 1.3.0 بسبعة عشر تصميمًا، والتصميم الافتراضي هو Row. @@ -71,6 +71,10 @@ + + + +
Terminal Nova
Terminal Nova
Arcade Nova
Arcade Nova
Meter
Meter
Notebook
Notebook
المعاينات مولَّدة من بيانات المشروع النموذجية. معاينات سطح المكتب والهاتف لكل تصميم موجودة في معرض القوالب. @@ -81,7 +85,7 @@ **لمشتركيك** -- **حالة حيّة.** حالة الباقة، والبيانات المستهلكة والمتبقية، وتاريخ الانتهاء، تُحدَّث من لوحتك طالما كانت الصفحة ظاهرة. +- **حالة حيّة.** حالة الباقة، والبيانات المستهلكة والمتبقية، وتاريخ الانتهاء، تُحدَّث من لوحتك طالما كانت الصفحة ظاهرة (في 3X-UI؛ أما في PasarGuard وRebecca فتعرض الصفحة القيم لحظة فتحها). - **استيراد بلمسة واحدة** إلى التطبيقات الشائعة، مرتّبة حسب المنصة: v2rayNG وHapp وsing-box على Android؛ وStreisand وV2Box وShadowrocket على iOS؛ وClash Verge Rev وMihomo Party وv2rayN على Windows؛ وClash Verge Rev وStreisand وV2Box على macOS. - **النسخ ورمز QR.** انسخ رابط الاشتراك أو امسحه كرمز QR يُولَّد داخل الصفحة. - **مستكشف الإعدادات.** كل خادم في صف خاص به، مع علم الدولة أو شارة بالأحرف الأولى (monogram) ووسم البروتوكول (VLESS وVMess وTrojan وShadowsocks وHysteria/Hysteria2 وWireGuard وAmneziaWG وTelegram MTProto)، مع رمز QR ونسخ لكل إعداد، وبحث في القوائم الطويلة. @@ -98,17 +102,17 @@ - **لا طلبات إلى أطراف ثالثة** من الصفحة المعروضة: لا شبكات CDN، ولا خدمات خارجية لرموز QR أو تحديد الموقع، ولا قياس عن بُعد (telemetry). تأتي الحالة الحيّة من لوحتك أنت. - **تحقق SHA-256 إلزامي** لكل تنزيل لإصدار، دون أي خيار لتجاوزه. - **تفعيل ذرّي.** تُولَّد الصفحة الجديدة ويُتحقَّق منها قبل أن تحل محل الصفحة الحالية، فلا تترك خطوة فاشلة صفحة معطوبة قيد العمل. -- **كشف حذر للوحة.** إذا لم تكن قاعدة بيانات اللوحة التي يعثر عليها Row-Template قاعدة بيانات SQLite صالحة، فإنه يرفض استخدامها بدلًا من تخمين قاعدة بيانات أخرى. +- **كشف حذر للوحة.** لا تُعدّ اللوحة مثبّتة إلا حين تتفق إشارات مستقلة؛ واللوحة المثبّتة جزئيًا، أو قاعدة بيانات اللوحة التي ليست قاعدة بيانات SQLite صالحة، تُرفض بدلًا من التخمين. ## اللوحات المدعومة | اللوحة | الحالة | ملاحظات | | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ مدعومة | تتطلب الإصدار **>= 3.6.0** | -| [PasarGuard](https://github.com/PasarGuard/panel) | 🔬 قيد البحث | غير مدعومة؛ لا يوجد مسار تثبيت | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | 🔬 قيد البحث | غير مدعومة؛ لا يوجد مسار تثبيت | +| [PasarGuard](https://github.com/PasarGuard/panel) | ✅ مدعومة منذ 1.3.0 | التثبيت الرسمي عبر Docker أو التثبيت من المصدر (`pasarguard.service`) | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ مدعومة منذ 1.3.0 | Rebecca الإصدار **1.x**، إصدار Go (التثبيت الثنائي لـ Rebecca). تفعيل تلقائي مع SQLite و`sqlite3`؛ ومع MySQL/MariaDB إعداد واحد يُدخَل في لوحة التحكم. صورة Docker ما زالت 0.0.x وتُرفض | -3X-UI هي اللوحة الوحيدة المدعومة. تستخدم PasarGuard وRebecca محرّكَي قوالب مختلفين (Jinja2 وpongo2)؛ يُبنى هيكل صفحة كل تصميم لهما ويُحزَم في الإصدار لأغراض الدراسة، لكن المثبّت لا يضعه في مكانه ولا توجد تعليمات تثبيت لهما. راجع [التوافق](https://iitzseridev.github.io/Row-Template/ar/compatibility/) للاطلاع على نتائج البحث. +تستخدم اللوحات الثلاث ثلاثة محرّكات قوالب مختلفة — `html/template` في Go وJinja2 وpongo2 — لذا يُبنى كل تصميم مرة لكل لوحة، ويُختبر كل إصدار منه بعرضه بمحرّك تلك اللوحة الحقيقي. يكتشف المثبّت اللوحة الموجودة على الخادم؛ وعلى خادم فيه أكثر من لوحة يسألك (أو يقرأ `RT_PANEL`). **مدعومة** تعني توفّر القدرات السبع كلها على تلك اللوحة — الاكتشاف والتثبيت والتفعيل والتحقق والنسخ الاحتياطي والاستعادة وإلغاء التثبيت — ويختبر كلًّا منها مجموعة الاختبارات. راجع [التوافق](https://iitzseridev.github.io/Row-Template/ar/compatibility/) لتفاصيل كل لوحة. ## البنية @@ -116,14 +120,14 @@ flowchart TB subgraph build ["Build and release"] direction LR - SRC["src/
runtime, styles, locales,
15 design layouts"] --> BUILD["tools/build.mjs"] - BUILD --> ART["One self-contained
HTML file per design"] + SRC["src/
runtime, styles, locales,
17 design layouts"] --> BUILD["tools/build.mjs"] + BUILD --> ART["One self-contained
HTML file per design,
per panel"] ART --> REL["tools/make-release.sh
tarball + SHA256SUMS"] end - subgraph host ["Your 3X-UI server"] + subgraph host ["Your panel server"] direction LR - INST["install.sh / row-template
verify checksum, stage, validate,
back up, activate"] --> DIR["/etc/3x-ui/
sub_templates/row-template"] - DIR -- "subThemeDir" --> XUI["3X-UI renders the page
with the subscriber's data"] + INST["install.sh / row-template
verify checksum, detect panel,
back up, activate, verify"] --> DIR["3X-UI: subThemeDir
PasarGuard: .env block
Rebecca: subscription settings"] + DIR --> XUI["The panel renders the page
with the subscriber's data"] end build -- "GitHub Releases" --> host host -- "serves the page" --> BROWSER["Subscriber's browser"] @@ -131,15 +135,15 @@ flowchart TB ``` - **ملف واحد لكل تصميم.** يضمّن `tools/build.mjs` الشيفرة المشتركة والترجمات والخطوط ومولّد QR داخل تخطيط كل تصميم، ويرفض أي تخطيط ينقصه أيٌّ من نقاط الربط (hooks) التي تحتاجها الشيفرة. ثم يرفض `tools/verify.mjs` أي ملف يحمّل شيئًا من مصدر بعيد أو يحتوي على بنية محظورة. -- **اللوحة هي من تعرض الصفحة.** الصفحة قالب: يملأ 3X-UI بيانات المشترك فيها عند تقديمها، ثم تحدّث الصفحة حالتها من اللوحة نفسها. -- **لا يعدّل المثبّت 3X-UI أبدًا.** يكتب في مجلده الخاص ويغيّر إعدادًا واحدًا في اللوحة، هو `subThemeDir`، ليشير إليه. +- **اللوحة هي من تعرض الصفحة.** الصفحة قالب: تملأ اللوحة بيانات المشترك فيها عند تقديمها. في PasarGuard (Jinja2) وRebecca (pongo2) يُغلَّف كل تصميم بمقدّمة صغيرة تربط بيانات اللوحة نفسها بالصفحة وتهرّب (escape) كل قيمة. +- **لا يعدّل المثبّت لوحتك أبدًا.** في 3X-UI يوجّه `subThemeDir` إلى مجلده الخاص؛ وفي PasarGuard يضع الصفحة في مجلد القوالب ويُلحق كتلة معلَّمة واحدة بـ`.env`؛ وفي Rebecca يضع الصفحة ويضبط حقلَي الصفحة والمجلد في إعدادات الاشتراك. تُلتقط لقطة لكل تغيير قبل إجرائه، ويُستعاد بدقة إن فشل أي شيء. | المسار | المحتوى | | ---- | ---------------- | | `src/` | شيفرة الصفحة وأنماطها وترجماتها؛ كل تصميم في `src/templates//` | | `template/index.html` | صفحة Row المبنية، وهي مُضمَّنة في المستودع | | `tools/` | البناء والتحقق والإصدار وعارض الـ fixtures المكتوب بـ Go | -| `installer/` | `install.sh` والأمر `row-template` ومكتبته الإدارية | +| `installer/` | `install.sh` والأمر `row-template` ومكتبته الإدارية، ومحوّل لكل لوحة في `installer/panels/` | | `tests/` | مجموعات الاختبارات | | `docs/` | موقع التوثيق؛ سجلات التصميم في [`docs/design/`](docs/design/README.md) | @@ -147,9 +151,9 @@ flowchart TB > **نظام التشغيل المُوصى به: Ubuntu 24.04 LTS (x86_64).** قد تعمل توزيعات Linux الحديثة الأخرى لكنها لم تحظَ بالمستوى نفسه من تغطية التحقق. -**المتطلبات:** خادم يشغّل 3X-UI **>= 3.6.0**، وصلاحية root عليه، و`curl` و`tar` و`sha256sum` (متوفرة على جميع أنظمة Linux تقريبًا). ويحتاج التفعيل التلقائي أيضًا إلى `sqlite3`. +**المتطلبات:** خادم يشغّل 3X-UI **>= 3.6.0** أو PasarGuard أو Rebecca **1.x**؛ وصلاحية root عليه؛ و`curl` و`tar` و`sha256sum` (متوفرة على جميع أنظمة Linux تقريبًا). ويحتاج التفعيل التلقائي في 3X-UI وRebecca أيضًا إلى `sqlite3`. -شغّل الأمر بصلاحية **root** على الخادم الذي يستضيف لوحة 3X-UI: +شغّل الأمر بصلاحية **root** على الخادم الذي يستضيف لوحتك: ```bash bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) @@ -159,7 +163,7 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d 1. ينزّل أحدث إصدار مستقر من GitHub. 2. يتحقق من مجموعه الاختباري SHA-256 (إلزامي — دون إمكانية التجاوز). -3. يستخرجه بأمان ويثبّته في `/etc/3x-ui/sub_templates/row-template`. +3. يكتشف لوحتك، ويستخرج الإصدار بأمان ويثبّته في `/etc/3x-ui/sub_templates/row-template` (3X-UI) أو `/etc/row-template` (PasarGuard وRebecca). 4. في التثبيت الجديد، يعرض أداة اختيار التصميم (يُبقي Enter على Row). 5. يطلب بيانات علامتك التجارية (اسم الخدمة، رابط الدعم، الشعار — وكلها اختيارية). 6. يولّد الصفحة ويتحقق منها، ثم يفعّلها في اللوحة حيثما أمكن. @@ -170,6 +174,12 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d RT_TEMPLATE=editorial bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) ``` +على خادم يشغّل أكثر من لوحة مدعومة، يسألك المثبّت عن اللوحة التي يخدمها؛ وفي سكربت، سمِّها عبر `RT_PANEL` (`3xui` أو `pasarguard` أو `rebecca`): + +```bash +RT_PANEL=pasarguard bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) +``` + إن كنت تفضّل عدم تمرير السكربت مباشرة من الشبكة، فنزّل ملفات الإصدار الأربعة (`install.sh`، `manifest.txt`، `SHA256SUMS`، `row-template-.tar.gz`) من [صفحة الإصدارات](https://github.com/iitzSeriZdev/Row-Template/releases/latest) إلى مجلد واحد، وتحقق من المجموع الاختباري بنفسك كما يشرح [PROVENANCE.md](PROVENANCE.md)، ثم وجّه المثبّت إلى ذلك المجلد: ```bash @@ -178,19 +188,39 @@ RT_RELEASE_DIR=/root/row-template-release bash /root/row-template-release/instal ### التفعيل -يُثبَّت Row-Template في مجلد تقدّمه اللوحة كصفحة اشتراك: +يعرض التثبيت التفاعلي ما سيغيّره التفعيل ويسألك أولًا. في PasarGuard وRebecca يجري التفعيل على هيئة معاملة: تُلتقط لقطة لحالة اللوحة، ثم يُطبَّق التغيير ويُتحقق منه، وإن فشلت أي خطوة تُستعاد اللوحة بدقة. + +**3X-UI.** يُثبَّت Row-Template في مجلد تقدّمه اللوحة كصفحة اشتراك: ``` /etc/3x-ui/sub_templates/row-template ``` -- **تلقائيًا:** عند توفر `sqlite3`، يضبطه Row-Template نيابةً عنك. يوقف خدمة اللوحة لفترة وجيزة، ويكتب الإعداد، ثم يشغّل الخدمة من جديد ويتحقق من القيمة. في التثبيت التفاعلي يعرض الإعداد الحالي ويسألك أولًا. +- **تلقائيًا:** عند توفر `sqlite3`، يضبطه Row-Template نيابةً عنك. يوقف خدمة اللوحة لفترة وجيزة، ويكتب الإعداد، ثم يشغّل الخدمة من جديد ويتحقق من القيمة. - **يدويًا:** خلاف ذلك، افتح **Panel Settings → Subscription → Profile → Sub Theme Directory** وأدخل القيمة التالية حرفيًا: ``` /etc/3x-ui/sub_templates/row-template ``` +**PasarGuard.** توضع الصفحة في `/var/lib/pasarguard/templates/row-template/index.html` (أو داخل `CUSTOM_TEMPLATES_DIRECTORY` الخاص بك إن كنت قد ضبطته)، وتُلحق كتلة معلَّمة بـ`/opt/pasarguard/.env`: + +``` +# >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# <<< row-template <<< +``` + +تقرأ PasarGuard ملف `.env` عند الإقلاع، لذا تُعاد تشغيل اللوحة العاملة مرة واحدة. لا يُعدَّل أي سطر من أسطرك؛ ويزيل إلغاء التثبيت الكتلة ويعيد `.env` إلى بايتاته السابقة بدقة. يبقى للمشرف الذي له قالب اشتراك خاص، أو لإعداد **disable subscription template**، الأولوية — ويخبرك `row-template verify` إن انطبق أيٌّ منهما. + +يدعم Row-Template الإصدار **1.x** من Rebecca، أي إصدار Go الذي تنشره Rebecca لتثبيتها الثنائي (`rebecca-binary.sh`). أما صورة `rebeccapanel/rebecca` على Docker Hub فما زالت إصدار 0.0.x المكتوب بـ Python، الذي لا يستطيع عرض هذه الصفحة؛ لذا يرفضها المثبّت ولا يغيّر شيئًا، والأمر `rebecca migrate-binary` الخاص بـ Rebecca ينقل تثبيت Docker إلى 1.x. + +**Rebecca.** توضع الصفحة في `/var/lib/rebecca/templates/row-template/index.html` (أو داخل مجلد القوالب المخصّص الخاص بك)، وتُضبط إعدادات الاشتراك في Rebecca على `row-template/index.html`. تقرأ Rebecca هذه الإعدادات مع كل طلب، فلا حاجة إلى إعادة التشغيل. + +- **تلقائيًا** مع قاعدة بيانات SQLite الافتراضية وتثبيت `sqlite3`. +- **يدويًا** مع MySQL/MariaDB (أو دون `sqlite3`): تبقى الصفحة موضوعة في مكانها؛ في لوحة تحكم Rebecca افتح **Settings → Subscription → Templates** واضبط **Subscription page template** على `row-template/index.html` و**Custom templates directory** على `/var/lib/rebecca/templates`. + ## الاستخدام شغّل المدير دون أي وسائط في الطرفية لفتح القائمة التفاعلية: @@ -206,23 +236,24 @@ row-template | `row-template config` | تغيير اسم الخدمة أو رابط الدعم أو الشعار، ثم إعادة توليد الصفحة | | `row-template update` | تنزيل أحدث إصدار مستقر والتحقق منه وتفعيله (التحقق من المجموع الاختباري إلزامي) | | `row-template rollback` | استعادة إصدار سابق (`--auto` أو `--to `) | -| `row-template verify` | فحص التثبيت وربط اللوحة والصفحة الحالية (للقراءة فقط) | -| `row-template version` | عرض الإصدار المثبّت والحد الأدنى المدعوم وإصدار 3X-UI المكتشف | -| `row-template uninstall` | إزالة Row-Template وإعادة اللوحة إلى صفحتها المدمجة | +| `row-template verify` | فحص التثبيت وربط اللوحة والصفحة الحالية (وبصلاحيات root يعيد أيضًا التصاميم المفقودة أو الموضوعة في غير مكانها) | +| `row-template version` | عرض الإصدار المثبّت واللوحة التي يخدمها (وفي 3X-UI أيضًا الحد الأدنى المدعوم والإصدار المكتشف) | +| `row-template uninstall` | إزالة Row-Template وإعادة اللوحة إلى الصفحة التي كانت لديها من قبل | | `row-template help` | عرض طريقة الاستخدام | يجب تشغيل الأوامر التي تغيّر النظام (`config` و`update` و`rollback` و`uninstall`) بصلاحية root. - **العلامة التجارية** تُخزَّن كبيانات، ولا تُنفَّذ أبدًا، وتُحقن في الصفحة كنص. اترك أي حقل فارغًا للحصول على صفحة بلا علامة تجارية. لا يقبل رابط الدعم إلا البروتوكولات التي ينبغي للمتصفح فتحها، مثل `https://…` أو `tg://…` أو `mailto:…`. - **التحديثات** تأتي من قناة الإصدارات المستقرة العامة. يطبّق `row-template update` دائمًا أحدث إصدار مستقر، حتى لو كان هو الإصدار المثبّت لديك؛ أما خيار **Update** في المدير فيقارن الإصدارات أولًا ويسأل قبل أي تغيير. إذا تعذّر الوصول إلى مصدر الإصدارات، لا يتغيّر شيء ولا يُعامَل تثبيتك أبدًا على أنه تالف. -- **التراجع** يستعيد إصدارًا سابقًا من نسخة احتياطية جرى التحقق منها. تُلتقط لقطة (snapshot) للإصدار الحالي أولًا، بحيث يمكن التعافي من تراجع فاشل، وتُحفَظ علامتك التجارية. -- **إلغاء التثبيت** يزيل ملفات Row-Template. ولا يمسح `subThemeDir` في اللوحة إلا إذا كان يشير إلى Row-Template، فتعود اللوحة إلى صفحتها المدمجة؛ ولا يمسّ الواردات (inbounds) أو العملاء أو الشهادات. +- **التحديث من 1.1.0 أو 1.2.x** يكفيه تشغيل `row-template update` مرة واحدة. ينسخ مُحدِّث 1.1.0 نفسه جزءًا فقط من الإصدار الجديد، لذا فإن التشغيل التالي لـ`row-template` أو `row-template config` أو `row-template verify` بصلاحيات root ينزّل أولًا بقية الإصدار نفسه — كل التصاميم، مع التحقق من checksum. ويُحفَظ تصميمك وعلامتك التجارية وربط اللوحة. +- **التراجع** يستعيد إصدارًا سابقًا من نسخة احتياطية جرى التحقق منها. تُلتقط لقطة (snapshot) للإصدار الحالي أولًا، بحيث يمكن التعافي من تراجع فاشل، وتُحفَظ علامتك التجارية. تسجّل النسخ الاحتياطية اللوحة التي أُنشئت عليها ولا تُستعاد أبدًا على لوحة أخرى؛ والنسخة الاحتياطية من إصدار أقدم لا تسجّل اسم تصميمها تُستعاد على أنها Row. +- **إلغاء التثبيت** يزيل ملفات Row-Template ويعيد اللوحة إلى الصفحة التي كانت لديها من قبل: في 3X-UI لا يمسح `subThemeDir` إلا إذا كان يشير إلى Row-Template؛ وفي PasarGuard يزيل كتلته من `.env` وصفحته؛ وفي Rebecca يستعيد إعدادَي الاشتراك اللذين غيّرهما (ويتركهما إن كنت قد اخترت صفحة أخرى منذ ذلك الحين). ولا يمسّ المستخدمين أو الواردات (inbounds) أو العملاء أو العُقد أو الشهادات. يغطي [التوثيق](https://iitzseridev.github.io/Row-Template/ar/) الإعداد والعلامة التجارية واستكشاف الأخطاء بمزيد من التفصيل. ## التطوير -تُبنى الصفحات من مصادر مقروءة في `src/`. تحتاج إلى Node.js 22 أو أحدث، وإلى Go 1.22 أو أحدث لتشغيل الاختبارات. +تُبنى الصفحات من مصادر مقروءة في `src/`. تحتاج إلى Node.js 22 أو أحدث؛ ولتشغيل الاختبارات أيضًا إلى Go 1.22 أو أحدث وPython 3 مع Jinja2 (`pip install jinja2`)، اللذين يعرضان صفحات PasarGuard وRebecca بمحرّكَي هاتين اللوحتين الحقيقيين. ```bash npm run build # regenerate template/index.html from src/ @@ -237,7 +268,7 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 ## الاختبار -- **`npm test`** يولّد أولًا صفحات الـ fixtures لكل التصاميم باستخدام العارض المكتوب بـ Go، ثم يشغّل مجموعات الاختبارات: سكربتات الصفحة، والبناء، والملف النهائي لكل تصميم، وحمولة الإصدار، والمثبّت — الذي تُشغَّل مكتبته الـ shell المنشورة في `bash` حقيقي على fixtures مؤقتة. +- **`npm test`** يولّد أولًا صفحات الـ fixtures لكل التصاميم باستخدام العارض المكتوب بـ Go، ثم يشغّل مجموعات الاختبارات: سكربتات الصفحة، والبناء، والملف النهائي لكل تصميم، وصفحات PasarGuard وRebecca معروضةً بـ Jinja2 وpongo2 الحقيقيين (بما في ذلك مع بيانات عدائية ومشوّهة)، وحمولة الإصدار، والمثبّت — الذي تُشغَّل مكتبته الـ shell المنشورة ومحوّل كل لوحة في `bash` حقيقي على خوادم مؤقتة مرتّبة كتثبيت كل لوحة الرسمي. - **`npm run verify`** يفحص صفحة مبنية وفق بوابات الأمان الخاصة بها، ومنها: مستند كامل، واستبدال كل علامات البناء، وتضمين كل شيء، وعدم وجود مراجع بعيدة، وعدم وجود بُنى محظورة، وسلامة الترجمات، وخلوّ المصادر من المحارف غير المرئية. - **`npm run lint:sh`** يفشل عند أي خطأ من ShellCheck؛ ويعرض `npm run lint:sh -- -S warning` التقرير الكامل. - **سير عمل Docs** يبني موقع التوثيق في كل طلب دمج (pull request) يغيّره. @@ -246,18 +277,17 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 توجّه، لا وعود: -- **Row-Template 1.2.0** — التصاميم الخمسة عشر وأداة اختيار التصميم الموصوفة أعلاه. -- **PasarGuard وRebecca** — قيد البحث. هيكل الصفحة مبنيّ لكليهما؛ وتحتاج الحالة الحيّة إلى تغيير صغير في الشيفرة أو إلى وكيل عكسي (reverse proxy)، وقد أُرجئ هذا القرار. راجع [التوافق](https://iitzseridev.github.io/Row-Template/ar/compatibility/). -- **التثبيت على أكثر من لوحة** — البنية التحتية للمثبّت (واجهة للوحات، ومحرّك معاملات، ومحوّل لـ 3X-UI، وصيغة نسخ احتياطي جديدة) موجودة، لكن لا يستخدمها أي أمر بعد. +- **Row-Template 1.3.0** — دعم PasarGuard وRebecca، وتصميما Meter وNotebook، الموصوفة أعلاه. +- **الحالة الحيّة في PasarGuard وRebecca** — تقدّمها كلتاهما على لاحقة مسار لا على `?format=info`؛ ويحتاج ربطها إلى تغيير صغير في الشيفرة، وقد أُرجئ هذا القرار. راجع [التوافق](https://iitzseridev.github.io/Row-Template/ar/compatibility/). - **القوالب المخصّصة** — مقترح لإضافة تصميمك الخاص: [`docs/design/CUSTOM-TEMPLATES-PROPOSAL.md`](docs/design/CUSTOM-TEMPLATES-PROPOSAL.md). ## المساهمة نرحّب كثيرًا بتقارير الأخطاء والترجمات وتصحيحات التوثيق. اقرأ [CONTRIBUTING.md](CONTRIBUTING.md) قبل فتح طلب دمج، والتزم بـ[مدونة السلوك](CODE_OF_CONDUCT.md). -**الإبلاغ عن الأخطاء:** افتح تذكرة (issue) على . أدرِج إصدار Row-Template لديك (`row-template version`)، وإصدار 3X-UI، ونظام التشغيل وإصداره، ومعمارية المعالج، ومخرجات `row-template verify`، وخطوات واضحة لإعادة إنتاج المشكلة. +**الإبلاغ عن الأخطاء:** افتح تذكرة (issue) على . أدرِج إصدار Row-Template لديك (`row-template version`)، ولوحتك وإصدارها، ونظام التشغيل وإصداره، ومعمارية المعالج، ومخرجات `row-template verify`، وخطوات واضحة لإعادة إنتاج المشكلة. -> **لا تُدرِج أي أسرار.** لا تلصق إطلاقًا روابط الاشتراك، أو قيم `subId`، أو معرّفات UUID الخاصة بالعملاء، أو أسماء مستخدمي اللوحة أو كلمات مرورها، أو ملفات تعريف الارتباط (cookies)، أو الرموز (tokens)، أو `webBasePath` الخاص باللوحة، أو مفاتيح TLS، أو عناوين الخوادم الحقيقية. ونقِّح السجلات قبل مشاركتها. +> **لا تُدرِج أي أسرار.** لا تلصق إطلاقًا روابط الاشتراك، أو قيم `subId`، أو معرّفات UUID الخاصة بالعملاء، أو أسماء مستخدمي اللوحة أو كلمات مرورها، أو ملفات تعريف الارتباط (cookies)، أو الرموز (tokens)، أو `webBasePath` الخاص باللوحة، أو محتوى `.env`، أو روابط قواعد البيانات، أو مفاتيح TLS، أو عناوين الخوادم الحقيقية. ونقِّح السجلات قبل مشاركتها. ## الأمان diff --git a/README.fa.md b/README.fa.md index 997222f..ba324b3 100644 --- a/README.fa.md +++ b/README.fa.md @@ -7,7 +7,7 @@

- یک صفحهٔ اشتراک شکیل و خودبسنده برای پنل های 3X-UI — پانزده طرح که هر کدام یک فایل HTML است، کاملاً وایت لیبل، و بدون هیچ درخواستی به شخص ثالث از صفحه ای که مشترکان شما باز می کنند. + یک صفحهٔ اشتراک شکیل و خودبسنده برای پنل های 3X-UI، PasarGuard و Rebecca — هفده طرح که هر کدام یک فایل HTML است، کاملاً وایت لیبل، و بدون هیچ درخواستی به شخص ثالث از صفحه ای که مشترکان شما باز می کنند.

@@ -17,7 +17,7 @@

License Latest release - Panel + Panels Platform Documentation

@@ -34,21 +34,21 @@ ## Row-Template چیست؟ -3X-UI می تواند به جای صفحهٔ داخلی خود، یک صفحهٔ سفارشی به مشترکان نشان دهد. Row-Template همان صفحه است: مشترک پیوند اشتراک خود را باز می کند و پلن، میزان مصرف و تاریخ انقضای خود را می بیند، به همراه راه هایی برای افزودن اشتراک با یک لمس به برنامه ای که استفاده می کند. +3X-UI، PasarGuard و Rebecca هر کدام می توانند به جای صفحهٔ داخلی خود، یک صفحهٔ سفارشی به مشترکان نشان دهند. Row-Template همان صفحه است: مشترک پیوند اشتراک خود را باز می کند و پلن، میزان مصرف و تاریخ انقضای خود را می بیند، به همراه راه هایی برای افزودن اشتراک با یک لمس به برنامه ای که استفاده می کند. -برای هر طرح یک فایل HTML خودبسنده عرضه می شود که همهٔ استایل ها، اسکریپت ها، فونت ها و مولد کد QR درون آن گنجانده شده اند. یک دستور آن را کنار پنل شما نصب می کند، پنل را به آن اشاره می دهد و ابزار مدیریتی `row-template` را برای برندسازی، به روزرسانی و بازگردانی در اختیار شما می گذارد. +برای هر طرح یک فایل HTML خودبسنده عرضه می شود که همهٔ استایل ها، اسکریپت ها، فونت ها و مولد کد QR درون آن گنجانده شده اند، و از هر طرح نسخه ای به زبان قالب خود هر پنل. یک دستور پنل شما را شناسایی می کند، صفحه را کنار آن نصب می کند، پنل را به آن اشاره می دهد و ابزار مدیریتی `row-template` را برای برندسازی، به روزرسانی و بازگردانی در اختیار شما می گذارد. ## چرا Row-Template؟ - **محرمانه از پایه.** صفحه ای که مشترکان شما باز می کنند هیچ درخواستی به شخص ثالث نمی فرستد. کدهای QR روی خود صفحه تولید می شوند و اطلاعات برندسازی شما به صورت متن تزریق می شود — هرگز اجرا نمی شود و هرگز به هیچ جایی فرستاده نمی شود. - **واقعاً وایت لیبل.** نام سرویس، پیوند پشتیبانی و لوگوی خودتان. هیچ چیزی روی صفحهٔ ارائه شده معرف Row-Template نیست. -- **پانزده طرح، هر کدام یک فایل.** ظاهری را انتخاب کنید که به سرویس شما می آید. همهٔ طرح ها ویژگی ها، زبان ها و بررسی های ایمنی یکسانی دارند. +- **هفده طرح، هر کدام یک فایل.** ظاهری را انتخاب کنید که به سرویس شما می آید. همهٔ طرح ها ویژگی ها، زبان ها و بررسی های ایمنی یکسانی دارند — روی هر پنل پشتیبانی شده. - **ساخته شده برای مشترکان شما.** نمای زندهٔ مصرف و انقضا، ورود (import) با یک لمس به برنامه های پرکاربرد، و فهرستی قابل جستجو از پیکربندی های جداگانه برای افزودن دستی یک سرور. -- **ایمن برای بهره برداری.** نسخه هایی که مجموع کنترلی آن ها بررسی می شود، فعال سازی اتمی و بازگردانی تک دستوری. هرگز 3X-UI را وصله نمی کند: تنها تنظیمی از پنل که تغییر می دهد، دایرکتوری صفحهٔ اشتراک (`subThemeDir`) است. +- **ایمن برای بهره برداری.** نسخه هایی که مجموع کنترلی آن ها بررسی می شود، فعال سازی تراکنشی که اگر گامی شکست بخورد پنل را دقیقاً به حالت قبل برمی گرداند، و بازگردانی تک دستوری. هرگز پنل شما را وصله نمی کند: در 3X-UI یک تنظیم (`subThemeDir`) را تغییر می دهد، در PasarGuard یک بلوک نشان دار به `.env` می افزاید، و در Rebecca دو فیلد از تنظیمات اشتراک را مقدار می دهد. ## طرح ها -Row-Template 1.2.0 با پانزده طرح عرضه می شود. طرح پیش فرض Row است. +Row-Template 1.3.0 با هفده طرح عرضه می شود. طرح پیش فرض Row است. @@ -72,6 +72,10 @@ Row-Template 1.2.0 با پانزده طرح عرضه می شود. طرح پیش + + + +
Terminal Nova
Terminal Nova
Arcade Nova
Arcade Nova
Meter
Meter
Notebook
Notebook
پیش نمایش ها با داده های نمونهٔ خود پروژه ساخته شده اند. پیش نمایش دسکتاپ و موبایل همهٔ طرح ها در گالری طرح ها موجود است. @@ -82,7 +86,7 @@ Row-Template 1.2.0 با پانزده طرح عرضه می شود. طرح پیش **برای مشترکان شما** -- **وضعیت زنده.** وضعیت پلن، ترافیک مصرف شده و باقی مانده و تاریخ انقضا، که تا وقتی صفحه دیده می شود از پنل شما به روز می شود. +- **وضعیت زنده.** وضعیت پلن، ترافیک مصرف شده و باقی مانده و تاریخ انقضا، که تا وقتی صفحه دیده می شود از پنل شما به روز می شود (در 3X-UI؛ در PasarGuard و Rebecca صفحه مقادیر لحظهٔ باز شدن را نشان می دهد). - **ورود با یک لمس** به برنامه های پرکاربرد، بر اساس پلتفرم: v2rayNG، Happ و sing-box در Android؛ Streisand، V2Box و Shadowrocket در iOS؛ Clash Verge Rev، Mihomo Party و v2rayN در Windows؛ Clash Verge Rev، Streisand و V2Box در macOS. - **کپی و QR.** پیوند اشتراک را کپی کنید یا آن را به صورت کد QR که روی خود صفحه ساخته می شود اسکن کنید. - **کاوشگر پیکربندی ها.** هر سرور در یک ردیف جداگانه، با پرچم کشور یا نشان حروف (monogram) و برچسب پروتکل (VLESS، VMess، Trojan، Shadowsocks، Hysteria/Hysteria2، WireGuard، AmneziaWG، Telegram MTProto)، به همراه QR و کپی برای هر پیکربندی و جستجو برای فهرست های طولانی. @@ -99,17 +103,17 @@ Row-Template 1.2.0 با پانزده طرح عرضه می شود. طرح پیش - **بدون درخواست به شخص ثالث** از صفحهٔ ارائه شده: بدون CDN، بدون جستجوی بیرونی QR یا موقعیت جغرافیایی، بدون تله متری. وضعیت زنده از پنل خود شما می آید. - **SHA-256 الزامی** برای هر دانلود نسخه، بدون هیچ گزینه ای برای رد کردن آن. - **فعال سازی اتمی.** صفحهٔ جدید پیش از جایگزینی صفحهٔ فعال ساخته و اعتبارسنجی می شود، بنابراین یک گام ناموفق هرگز صفحه ای خراب را فعال باقی نمی گذارد. -- **شناسایی محتاطانهٔ پنل.** اگر پایگاه دادهٔ پنلی که Row-Template پیدا می کند یک پایگاه دادهٔ SQLite معتبر نباشد، به جای حدس زدن پایگاه دادهٔ دیگری، از به کار بردن آن خودداری می کند. +- **شناسایی محتاطانهٔ پنل.** یک پنل تنها وقتی نصب شده به حساب می آید که نشانه های مستقل با هم بخوانند؛ پنلی که نیمه نصب شده، یا پایگاه دادهٔ پنلی که یک پایگاه دادهٔ SQLite معتبر نیست، به جای حدس زدن رد می شود. ## پنل های پشتیبانی شده | پنل | وضعیت | یادداشت ها | | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ پشتیبانی شده | نیازمند نسخهٔ **>= 3.6.0** | -| [PasarGuard](https://github.com/PasarGuard/panel) | 🔬 در حال پژوهش | پشتیبانی نمی شود؛ مسیر نصبی ندارد | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | 🔬 در حال پژوهش | پشتیبانی نمی شود؛ مسیر نصبی ندارد | +| [PasarGuard](https://github.com/PasarGuard/panel) | ✅ پشتیبانی شده از 1.3.0 | نصب رسمی Docker یا نصب از سورس (`pasarguard.service`) | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ پشتیبانی شده از 1.3.0 | Rebecca نسخهٔ **1.x**، نسخهٔ Go (نصب باینری Rebecca). فعال سازی خودکار با SQLite و `sqlite3`؛ با MySQL/MariaDB یک تنظیم که باید در داشبورد وارد شود. ایمیج Docker هنوز 0.0.x است و رد می شود | -تنها پنل پشتیبانی شده 3X-UI است. PasarGuard و Rebecca از موتورهای قالب متفاوتی (Jinja2 و pongo2) استفاده می کنند؛ پوستهٔ صفحهٔ هر طرح برای آن ها ساخته و برای بررسی در نسخه بسته بندی می شود، اما نصب کننده آن را جایگذاری نمی کند و هیچ دستورالعمل نصبی برای آن ها وجود ندارد. برای یافته های پژوهشی، [سازگاری](https://iitzseridev.github.io/Row-Template/fa/compatibility/) را ببینید. +این سه پنل از سه موتور قالب متفاوت استفاده می کنند — `html/template` زبان Go، Jinja2 و pongo2 — پس هر طرح برای هر پنل یک بار ساخته می شود و هر نسخه با رندر شدن توسط موتور واقعی همان پنل آزموده می شود. نصب کننده تشخیص می دهد کدام پنل روی سرور است؛ روی سروری با بیش از یک پنل، از شما می پرسد (یا `RT_PANEL` را می خواند). **پشتیبانی‌شده** یعنی هر هفت توانایی روی آن پنل موجود است — تشخیص، نصب، فعال‌سازی، بررسی، پشتیبان‌گیری، بازگردانی و حذف نصب — و هر کدام توسط مجموعهٔ آزمون آزموده می شود. برای جزئیات هر پنل، [سازگاری](https://iitzseridev.github.io/Row-Template/fa/compatibility/) را ببینید. ## معماری @@ -117,14 +121,14 @@ Row-Template 1.2.0 با پانزده طرح عرضه می شود. طرح پیش flowchart TB subgraph build ["Build and release"] direction LR - SRC["src/
runtime, styles, locales,
15 design layouts"] --> BUILD["tools/build.mjs"] - BUILD --> ART["One self-contained
HTML file per design"] + SRC["src/
runtime, styles, locales,
17 design layouts"] --> BUILD["tools/build.mjs"] + BUILD --> ART["One self-contained
HTML file per design,
per panel"] ART --> REL["tools/make-release.sh
tarball + SHA256SUMS"] end - subgraph host ["Your 3X-UI server"] + subgraph host ["Your panel server"] direction LR - INST["install.sh / row-template
verify checksum, stage, validate,
back up, activate"] --> DIR["/etc/3x-ui/
sub_templates/row-template"] - DIR -- "subThemeDir" --> XUI["3X-UI renders the page
with the subscriber's data"] + INST["install.sh / row-template
verify checksum, detect panel,
back up, activate, verify"] --> DIR["3X-UI: subThemeDir
PasarGuard: .env block
Rebecca: subscription settings"] + DIR --> XUI["The panel renders the page
with the subscriber's data"] end build -- "GitHub Releases" --> host host -- "serves the page" --> BROWSER["Subscriber's browser"] @@ -132,15 +136,15 @@ flowchart TB ``` - **یک فایل برای هر طرح.** `tools/build.mjs` کد اجرایی مشترک، ترجمه ها، فونت ها و مولد QR را درون چیدمان هر طرح می گنجاند و چیدمانی را که هر یک از قلاب های (hook) مورد نیاز کد اجرایی را نداشته باشد رد می کند. سپس `tools/verify.mjs` هر فایلی را که چیزی را از راه دور بارگذاری کند یا ساختاری ممنوع داشته باشد رد می کند. -- **رندر را پنل انجام می دهد.** صفحه یک قالب است: 3X-UI هنگام ارائهٔ آن داده های مشترک را در آن قرار می دهد و سپس صفحه وضعیت خود را از همان پنل به روز می کند. -- **نصب کننده هرگز 3X-UI را ویرایش نمی کند.** دایرکتوری خودش را می نویسد و تنها یک تنظیم پنل، `subThemeDir`، را تغییر می دهد تا به آن اشاره کند. +- **رندر را پنل انجام می دهد.** صفحه یک قالب است: پنل هنگام ارائهٔ آن داده های مشترک را در آن قرار می دهد. برای PasarGuard (Jinja2) و Rebecca (pongo2) هر طرح درون یک پیش درآمد کوچک قرار می گیرد که داده های خود پنل را به صفحه نگاشت می کند و هر مقدار را escape می کند. +- **نصب کننده هرگز پنل شما را وصله نمی کند.** در 3X-UI، `subThemeDir` را به دایرکتوری خودش اشاره می دهد؛ در PasarGuard صفحه را در دایرکتوری قالب ها می گذارد و یک بلوک نشان دار به انتهای `.env` می افزاید؛ در Rebecca صفحه را می گذارد و فیلدهای صفحه و دایرکتوری تنظیمات اشتراک را مقدار می دهد. از هر تغییر پیش از انجام یک snapshot گرفته می شود و اگر چیزی شکست بخورد دقیقاً بازگردانده می شود. | مسیر | محتوا | | ---- | ---------------- | | `src/` | کد اجرایی، استایل ها و ترجمه های صفحه؛ هر طرح در `src/templates//` | | `template/index.html` | صفحهٔ ساخته شدهٔ Row، که commit شده است | | `tools/` | ساخت، اعتبارسنجی، انتشار و رندرکنندهٔ Go برای fixtureها | -| `installer/` | `install.sh`، دستور `row-template` و کتابخانهٔ مدیریتی آن | +| `installer/` | `install.sh`، دستور `row-template`، کتابخانهٔ مدیریتی آن و یک آداپتور برای هر پنل در `installer/panels/` | | `tests/` | مجموعه های آزمون | | `docs/` | سایت مستندات؛ سوابق طراحی در [`docs/design/`](docs/design/README.md) | @@ -148,9 +152,9 @@ flowchart TB > **سیستم عامل پیشنهادی: Ubuntu 24.04 LTS (x86_64).** دیگر توزیع های امروزی لینوکس نیز ممکن است کار کنند، اما پوشش اعتبارسنجی یکسانی نداشته اند. -**پیش نیازها:** سروری با 3X-UI **>= 3.6.0**، دسترسی root به آن، و `curl`، `tar` و `sha256sum` (که تقریباً روی همهٔ سیستم های لینوکس موجود است). فعال سازی خودکار به `sqlite3` هم نیاز دارد. +**پیش نیازها:** سروری با 3X-UI **>= 3.6.0**، PasarGuard یا Rebecca **1.x**؛ دسترسی root به آن؛ و `curl`، `tar` و `sha256sum` (که تقریباً روی همهٔ سیستم های لینوکس موجود است). فعال سازی خودکار در 3X-UI و Rebecca به `sqlite3` هم نیاز دارد. -با کاربر **root** روی سروری که پنل 3X-UI شما را میزبانی می کند اجرا کنید: +با کاربر **root** روی سروری که پنل شما را میزبانی می کند اجرا کنید: ```bash bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) @@ -160,7 +164,7 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d 1. آخرین نسخهٔ پایدار را از GitHub دانلود می کند. 2. مجموع کنترلی SHA-256 آن را بررسی می کند (الزامی — بدون امکان دور زدن). -3. آن را به شکل ایمن استخراج می کند و در `/etc/3x-ui/sub_templates/row-template` نصب می کند. +3. پنل شما را شناسایی می کند، نسخه را به شکل ایمن استخراج می کند و در `/etc/3x-ui/sub_templates/row-template` (3X-UI) یا `/etc/row-template` (PasarGuard، Rebecca) نصب می کند. 4. در نصب تازه، انتخابگر طرح را نشان می دهد (Enter طرح Row را نگه می دارد). 5. برای برندسازی شما درخواست ورودی می دهد (نام سرویس، پیوند پشتیبانی، لوگو — همگی اختیاری). 6. صفحه را تولید و اعتبارسنجی می کند و سپس در صورت امکان آن را در پنل فعال می کند. @@ -171,6 +175,12 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d RT_TEMPLATE=editorial bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) ``` +روی سروری که بیش از یک پنل پشتیبانی شده دارد، نصب کننده می پرسد کدام را سرویس دهد؛ در یک اسکریپت، آن را با `RT_PANEL` (`3xui`، `pasarguard` یا `rebecca`) مشخص کنید: + +```bash +RT_PANEL=pasarguard bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) +``` + اگر ترجیح می دهید از طریق شبکه به صورت pipe عمل نکنید، چهار فایل نسخه (`install.sh`، `manifest.txt`، `SHA256SUMS` و `row-template-.tar.gz`) را از [صفحهٔ Releases](https://github.com/iitzSeriZdev/Row-Template/releases/latest) در یک پوشه دانلود کنید، مجموع کنترلی را خودتان همان گونه که در [PROVENANCE.md](PROVENANCE.md) توضیح داده شده بررسی کنید و نصب کننده را به آن پوشه ارجاع دهید: ```bash @@ -179,19 +189,39 @@ RT_RELEASE_DIR=/root/row-template-release bash /root/row-template-release/instal ### فعال سازی -Row-Template در دایرکتوری ای نصب می شود که پنل آن را به عنوان صفحهٔ اشتراک ارائه می دهد: +نصب تعاملی ابتدا نشان می دهد فعال سازی چه چیزی را تغییر می دهد و پیش از تغییر از شما می پرسد. در PasarGuard و Rebecca فعال سازی به صورت یک تراکنش اجرا می شود: از وضعیت پنل snapshot گرفته می شود، تغییر اعمال و بررسی می شود، و اگر گامی شکست بخورد، پنل دقیقاً به حالت قبل بازگردانده می شود. + +**3X-UI.** Row-Template در دایرکتوری ای نصب می شود که پنل آن را به عنوان صفحهٔ اشتراک ارائه می دهد: ``` /etc/3x-ui/sub_templates/row-template ``` -- **خودکار:** هنگامی که `sqlite3` در دسترس باشد، Row-Template آن را برای شما تنظیم می کند. سرویس پنل را برای مدت کوتاهی متوقف می کند، تنظیم را می نویسد، سرویس را دوباره راه اندازی می کند و مقدار را بررسی می کند. نصب تعاملی ابتدا تنظیم فعلی را نشان می دهد و پیش از تغییر از شما می پرسد. +- **خودکار:** هنگامی که `sqlite3` در دسترس باشد، Row-Template آن را برای شما تنظیم می کند. سرویس پنل را برای مدت کوتاهی متوقف می کند، تنظیم را می نویسد، سرویس را دوباره راه اندازی می کند و مقدار را بررسی می کند. - **دستی:** در غیر این صورت، **Panel Settings → Subscription → Profile → Sub Theme Directory** را باز کنید و دقیقاً این را وارد کنید: ``` /etc/3x-ui/sub_templates/row-template ``` +**PasarGuard.** صفحه در `/var/lib/pasarguard/templates/row-template/index.html` قرار می گیرد (یا درون `CUSTOM_TEMPLATES_DIRECTORY` خودتان، اگر تنظیمش کرده باشید)، و یک بلوک نشان دار به انتهای `/opt/pasarguard/.env` افزوده می شود: + +``` +# >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# <<< row-template <<< +``` + +PasarGuard فایل `.env` را هنگام راه اندازی می خواند، پس پنلی که در حال اجراست یک بار راه اندازی مجدد می شود. هیچ یک از خط های خودتان ویرایش نمی شود؛ حذف نصب بلوک را برمی دارد و `.env` را دقیقاً به بایت های قبلی اش بازمی گرداند. ادمینی که قالب اشتراک خودش را دارد، یا تنظیم **disable subscription template**، همچنان مقدم است — `row-template verify` به شما می گوید اگر یکی از آن ها برقرار باشد. + +Row-Template از Rebecca نسخهٔ **1.x** پشتیبانی می کند، یعنی نسخهٔ Go که Rebecca برای نصب باینری خود (`rebecca-binary.sh`) منتشر می کند. ایمیج `rebeccapanel/rebecca` در Docker Hub هنوز نسخهٔ 0.0.x پایتونی است که نمی تواند این صفحه را رندر کند؛ نصب کننده آن را رد می کند و چیزی را تغییر نمی دهد، و دستور `rebecca migrate-binary` خود Rebecca یک نصب Docker را به 1.x منتقل می کند. + +**Rebecca.** صفحه در `/var/lib/rebecca/templates/row-template/index.html` قرار می گیرد (یا درون دایرکتوری قالب های سفارشی خودتان)، و تنظیمات اشتراک Rebecca روی `row-template/index.html` تنظیم می شود. Rebecca این تنظیمات را در هر درخواست می خواند، پس نیازی به راه اندازی مجدد نیست. + +- **خودکار** با پایگاه دادهٔ پیش فرض SQLite و نصب بودن `sqlite3`. +- **دستی** با MySQL/MariaDB (یا بدون `sqlite3`): صفحه همچنان جایگذاری می شود؛ در داشبورد Rebecca، **Settings → Subscription → Templates** را باز کنید و **Subscription page template** را `row-template/index.html` و **Custom templates directory** را `/var/lib/rebecca/templates` قرار دهید. + ## استفاده مدیر را بدون هیچ آرگومانی در ترمینال اجرا کنید تا منوی تعاملی باز شود: @@ -207,23 +237,24 @@ row-template | `row-template config` | تغییر نام سرویس، پیوند پشتیبانی یا لوگو و سپس بازسازی صفحه | | `row-template update` | دانلود، بررسی و فعال سازی آخرین نسخهٔ پایدار (بررسی مجموع کنترلی الزامی) | | `row-template rollback` | بازگردانی یک نسخهٔ پیشین (`--auto` یا `--to `) | -| `row-template verify` | بررسی نصب، اتصال به پنل و صفحهٔ فعال (فقط خواندنی) | -| `row-template version` | نمایش نسخهٔ نصب شده، حداقل نسخهٔ پشتیبانی شده و نسخهٔ شناسایی شدهٔ 3X-UI | -| `row-template uninstall` | حذف Row-Template و بازگرداندن پنل به صفحهٔ داخلی خودش | +| `row-template verify` | بررسی نصب، اتصال به پنل و صفحهٔ فعال (با دسترسی root، طرح های گم شده یا جابه جا شده را هم به جای خود برمی گرداند) | +| `row-template version` | نمایش نسخهٔ نصب شده و پنلی که به آن سرویس می دهد (در 3X-UI، حداقل نسخهٔ پشتیبانی شده و نسخهٔ شناسایی شده را هم) | +| `row-template uninstall` | حذف Row-Template و بازگرداندن پنل به صفحه ای که پیش تر داشت | | `row-template help` | نمایش راهنمای استفاده | دستورهایی که سیستم را تغییر می دهند (`config`، `update`، `rollback`، `uninstall`) باید با root اجرا شوند. - **برندسازی** به عنوان داده ذخیره می شود، هرگز اجرا نمی شود و به صورت متن در صفحه تزریق می گردد. برای یک صفحهٔ بدون برند، فیلدی را خالی بگذارید. پیوند پشتیبانی تنها پروتکل هایی را می پذیرد که مرورگر باید باز کند، مانند `https://…`، `tg://…` یا `mailto:…`. - **به روزرسانی ها** از کانال عمومی نسخه های پایدار می آیند. `row-template update` همیشه آخرین نسخهٔ پایدار را اعمال می کند، حتی اگر همان نسخه را داشته باشید؛ گزینهٔ **Update** در منوی مدیریت ابتدا نسخه ها را مقایسه می کند و پیش از هر تغییری می پرسد. اگر منبع انتشار در دسترس نباشد، چیزی تغییر نمی کند و نصب شما هرگز آسیب دیده تلقی نمی شود. -- **بازگردانی** یک نسخهٔ پیشین را از یک پشتیبان اعتبارسنجی شده بازیابی می کند. ابتدا از نسخهٔ فعلی یک عکس فوری (snapshot) گرفته می شود تا یک بازگردانی ناموفق قابل جبران باشد، و برندسازی شما حفظ می شود. -- **حذف نصب** فایل های Row-Template را حذف می کند. `subThemeDir` پنل را تنها در صورتی پاک می کند که به Row-Template اشاره کند، تا پنل به صفحهٔ داخلی خود بازگردد؛ به inboundها، کلاینت ها و گواهی های شما دست زده نمی شود. +- **به روزرسانی از 1.1.0 یا 1.2.x** با یک بار اجرای `row-template update` انجام می شود. به روزرسان خود 1.1.0 فقط بخشی از نسخهٔ جدید را کپی می کند، برای همین اجرای بعدی `row-template`، `row-template config` یا `row-template verify` با دسترسی root، ابتدا بقیهٔ همان نسخه را دریافت می کند — همهٔ طرح ها، با بررسی checksum. طرح، برندسازی و اتصال پنل شما حفظ می شوند. +- **بازگردانی** یک نسخهٔ پیشین را از یک پشتیبان اعتبارسنجی شده بازیابی می کند. ابتدا از نسخهٔ فعلی یک عکس فوری (snapshot) گرفته می شود تا یک بازگردانی ناموفق قابل جبران باشد، و برندسازی شما حفظ می شود. پشتیبان ها پنلی را که روی آن ساخته شده اند ثبت می کنند و هرگز روی پنل دیگری بازگردانده نمی شوند؛ پشتیبانی از یک نسخهٔ قدیمی تر که نام طرحش را ثبت نکرده، به صورت Row بازگردانده می شود. +- **حذف نصب** فایل های Row-Template را حذف می کند و پنل را به صفحه ای که پیش تر داشت بازمی گرداند: در 3X-UI، `subThemeDir` را تنها در صورتی پاک می کند که به Row-Template اشاره کند؛ در PasarGuard بلوک `.env` و صفحهٔ خودش را برمی دارد؛ در Rebecca دو تنظیم اشتراکی را که تغییر داده بازمی گرداند (و اگر از آن پس صفحهٔ دیگری انتخاب کرده باشید، به آن ها دست نمی زند). به کاربران، inboundها، کلاینت ها، نودها و گواهی های شما دست زده نمی شود. [مستندات](https://iitzseridev.github.io/Row-Template/fa/) پیکربندی، برندسازی و رفع اشکال را با جزئیات بیشتری پوشش می دهد. ## توسعه -صفحه ها از منابع خوانای موجود در `src/` ساخته می شوند. به Node.js نسخهٔ 22 یا بالاتر، و برای اجرای آزمون ها به Go نسخهٔ 1.22 یا بالاتر نیاز دارید. +صفحه ها از منابع خوانای موجود در `src/` ساخته می شوند. به Node.js نسخهٔ 22 یا بالاتر نیاز دارید؛ برای اجرای آزمون ها همچنین به Go نسخهٔ 1.22 یا بالاتر و Python 3 همراه Jinja2 (`pip install jinja2`) که صفحه های PasarGuard و Rebecca را با موتورهای واقعی همان پنل ها رندر می کنند. ```bash npm run build # regenerate template/index.html from src/ @@ -238,7 +269,7 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 ## آزمون -- **`npm test`** ابتدا صفحه های fixture همهٔ طرح ها را با رندرکنندهٔ Go می سازد و سپس مجموعه های آزمون را اجرا می کند: اسکریپت های صفحه، فرآیند ساخت، فایل نهایی هر طرح، محتوای بستهٔ انتشار و نصب کننده — که کتابخانهٔ shell منتشرشده را در یک `bash` واقعی روی fixtureهای موقت اجرا می کند. +- **`npm test`** ابتدا صفحه های fixture همهٔ طرح ها را با رندرکنندهٔ Go می سازد و سپس مجموعه های آزمون را اجرا می کند: اسکریپت های صفحه، فرآیند ساخت، فایل نهایی هر طرح، صفحه های PasarGuard و Rebecca که با Jinja2 و pongo2 واقعی رندر می شوند (از جمله با داده های مخرب و ناقص)، محتوای بستهٔ انتشار و نصب کننده — که کتابخانهٔ shell منتشرشده و آداپتور هر پنل را در یک `bash` واقعی روی میزبان های موقتی اجرا می کند که مانند نصب رسمی هر پنل چیده شده اند. - **`npm run verify`** یک صفحهٔ ساخته شده را با دروازه های ایمنی آن می سنجد، از جمله: سند کامل، جایگزینی همهٔ نشانگرهای ساخت، گنجاندن همه چیز در فایل، نبود ارجاع راه دور، نبود ساختارهای ممنوع، سالم بودن ترجمه ها و نبود نویسه های نامرئی در منابع. - **`npm run lint:sh`** با هر خطای ShellCheck شکست می خورد؛ `npm run lint:sh -- -S warning` گزارش کامل را نشان می دهد. - **گردش کار Docs** سایت مستندات را در هر pull request که آن را تغییر دهد می سازد. @@ -247,18 +278,17 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 جهت گیری، نه وعده: -- **Row-Template 1.2.0** — پانزده طرح و انتخابگر طرح که در بالا توضیح داده شد. -- **PasarGuard و Rebecca** — در حال پژوهش. پوستهٔ صفحه برای هر دو ساخته شده است؛ وضعیت زنده به یک تغییر کوچک در کد اجرایی یا یک reverse proxy نیاز دارد و این تصمیم به تعویق افتاده است. [سازگاری](https://iitzseridev.github.io/Row-Template/fa/compatibility/) را ببینید. -- **نصب روی بیش از یک پنل** — زیرساخت نصب کننده (یک رابط پنل، یک موتور تراکنش، یک آداپتور 3X-UI و یک قالب پشتیبان گیری جدید) آماده است، اما هنوز هیچ دستوری از آن استفاده نمی کند. +- **Row-Template 1.3.0** — پشتیبانی از PasarGuard و Rebecca، و طرح های Meter و Notebook، که در بالا توضیح داده شد. +- **وضعیت زنده در PasarGuard و Rebecca** — هر دو آن را روی یک پسوند مسیر ارائه می دهند نه `?format=info`؛ اتصال آن به یک تغییر کوچک در کد اجرایی نیاز دارد و این تصمیم به تعویق افتاده است. [سازگاری](https://iitzseridev.github.io/Row-Template/fa/compatibility/) را ببینید. - **قالب های سفارشی** — پیشنهادی برای افزودن طرح خودتان: [`docs/design/CUSTOM-TEMPLATES-PROPOSAL.md`](docs/design/CUSTOM-TEMPLATES-PROPOSAL.md). ## مشارکت گزارش اشکال، ترجمه و اصلاح مستندات بسیار استقبال می شود. پیش از باز کردن pull request، [CONTRIBUTING.md](CONTRIBUTING.md) را بخوانید و از [آیین نامهٔ رفتاری](CODE_OF_CONDUCT.md) پیروی کنید. -**گزارش اشکال:** یک issue در باز کنید. نسخهٔ Row-Template خود (`row-template version`)، نسخهٔ 3X-UI، سیستم عامل و نسخهٔ آن، معماری پردازنده، خروجی `row-template verify` و گام های روشن برای بازتولید مشکل را ذکر کنید. +**گزارش اشکال:** یک issue در باز کنید. نسخهٔ Row-Template خود (`row-template version`)، پنل شما و نسخهٔ آن، سیستم عامل و نسخهٔ آن، معماری پردازنده، خروجی `row-template verify` و گام های روشن برای بازتولید مشکل را ذکر کنید. -> **هیچ گونه اطلاعات محرمانه درج نکنید.** هرگز URLهای اشتراک، مقادیر `subId`، UUIDهای کلاینت، نام کاربری یا گذرواژهٔ پنل، کوکی ها، توکن ها، `webBasePath` پنل، کلیدهای TLS یا نشانی های واقعی سرور را وارد نکنید. پیش از اشتراک گذاری لاگ ها، آن ها را ویرایش و پاک سازی کنید. +> **هیچ گونه اطلاعات محرمانه درج نکنید.** هرگز URLهای اشتراک، مقادیر `subId`، UUIDهای کلاینت، نام کاربری یا گذرواژهٔ پنل، کوکی ها، توکن ها، `webBasePath` پنل، محتوای `.env`، URLهای پایگاه داده، کلیدهای TLS یا نشانی های واقعی سرور را وارد نکنید. پیش از اشتراک گذاری لاگ ها، آن ها را ویرایش و پاک سازی کنید. ## امنیت diff --git a/README.md b/README.md index f94c223..feb54c0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

- A polished, self-contained subscription page for 3X-UI panels — fifteen designs, each a single HTML file, fully white-label, with no third-party requests from the page your subscribers open. + A polished, self-contained subscription page for 3X-UI, PasarGuard and Rebecca panels — seventeen designs, each a single HTML file, fully white-label, with no third-party requests from the page your subscribers open.

@@ -17,7 +17,7 @@

License Latest release - Panel + Panels Platform Documentation

@@ -34,21 +34,21 @@ ## What it is -3X-UI can serve a custom page to subscribers instead of its built-in one. Row-Template is that page: a subscriber opens their subscription link and sees their plan, their usage, their expiry date, and one-tap ways to add the subscription to the app they use. +3X-UI, PasarGuard and Rebecca can each serve a custom page to subscribers instead of their built-in one. Row-Template is that page: a subscriber opens their subscription link and sees their plan, their usage, their expiry date, and one-tap ways to add the subscription to the app they use. -It ships as one self-contained HTML file per design, with every style, script, font, and the QR code generator inlined. A single command installs it next to your panel, points the panel at it, and gives you a `row-template` manager for branding, updates, and rollback. +It ships as one self-contained HTML file per design, with every style, script, font, and the QR code generator inlined, and a version of each design in every panel's own template language. A single command detects your panel, installs the page next to it, points the panel at it, and gives you a `row-template` manager for branding, updates, and rollback. ## Why Row-Template? - **Private by design.** The page your subscribers open makes no third-party requests. QR codes are generated on the page, and your branding is injected as text — never executed, never sent anywhere. - **Genuinely white-label.** Your service name, your support link, your logo. Nothing on the served page identifies Row-Template. -- **Fifteen designs, one file each.** Pick the look that fits your service. Every design shares the same features, languages, and safety checks. +- **Seventeen designs, one file each.** Pick the look that fits your service. Every design shares the same features, languages, and safety checks — on every supported panel. - **Made for your subscribers.** Live usage and expiry, one-tap import into popular apps, and a searchable list of individual configurations for adding a single server by hand. -- **Safe to operate.** Checksum-verified releases, atomic activation, and one-command rollback. It never patches 3X-UI: the only panel setting it changes is the subscription page directory (`subThemeDir`). +- **Safe to operate.** Checksum-verified releases, transactional activation that restores the panel exactly if any step fails, and one-command rollback. It never patches your panel: on 3X-UI it changes one setting (`subThemeDir`), on PasarGuard it adds one marked block to `.env`, and on Rebecca it sets two fields of its subscription settings. ## Designs -Row-Template 1.2.0 ships fifteen designs. Row is the default. +Row-Template 1.3.0 ships seventeen designs. Row is the default. @@ -72,6 +72,10 @@ Row-Template 1.2.0 ships fifteen designs. Row is the default. + + + +
Terminal Nova
Terminal Nova
Arcade Nova
Arcade Nova
Meter
Meter
Notebook
Notebook
Previews are rendered from the project's own placeholder data. Desktop and mobile previews of every design are in the template gallery. @@ -82,7 +86,7 @@ Choose a design during a fresh interactive install, set `RT_TEMPLATE` for a scri **For your subscribers** -- **Live status.** Plan state, traffic used and remaining, and expiry, refreshed from your panel while the page is visible. +- **Live status.** Plan state, traffic used and remaining, and expiry, refreshed from your panel while the page is visible (3X-UI; on PasarGuard and Rebecca the page shows the values as of when it was opened). - **One-tap import** into popular apps, grouped by platform: v2rayNG, Happ and sing-box on Android; Streisand, V2Box and Shadowrocket on iOS; Clash Verge Rev, Mihomo Party and v2rayN on Windows; Clash Verge Rev, Streisand and V2Box on macOS. - **Copy and QR.** Copy the subscription link or scan it as a QR code generated on the page. - **Configuration Explorer.** Every server on its own row, with a country flag or monogram and a protocol label (VLESS, VMess, Trojan, Shadowsocks, Hysteria/Hysteria2, WireGuard, AmneziaWG, Telegram MTProto), plus per-configuration QR and copy, and search for long lists. @@ -99,17 +103,17 @@ Choose a design during a fresh interactive install, set `RT_TEMPLATE` for a scri - **No third-party requests** from the served page: no CDNs, no external QR or geolocation lookups, no telemetry. Live status comes from your own panel. - **Mandatory SHA-256** verification of every release download, with no option to skip it. - **Atomic activation.** A new page is generated and validated before it replaces the live one, so a failed step never leaves a broken page live. -- **Fail-closed panel detection.** If the panel database Row-Template finds is not a valid SQLite database, it refuses to use it rather than guessing another one. +- **Fail-closed panel detection.** A panel counts as installed only when independent signals agree; a half-installed panel, or a panel database that is not a valid SQLite database, is refused rather than guessed at. ## Supported panels | Panel | Status | Notes | | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ Supported | Requires version **>= 3.6.0** | -| [PasarGuard](https://github.com/PasarGuard/panel) | 🔬 Research | Not supported; no installation path | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | 🔬 Research | Not supported; no installation path | +| [PasarGuard](https://github.com/PasarGuard/panel) | ✅ Supported since 1.3.0 | The official Docker install or a source install (`pasarguard.service`) | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ Supported since 1.3.0 | Rebecca **1.x**, the Go edition (Rebecca's binary install). Automatic activation with SQLite and `sqlite3`; with MySQL/MariaDB, one setting to enter in the dashboard. The Docker image is still 0.0.x and is refused | -3X-UI is the only supported panel. PasarGuard and Rebecca use different template engines (Jinja2 and pongo2); each design's page shell is built for them and packaged in the release for study, but the installer does not place it and there are no installation instructions for them. See [Compatibility](https://iitzseridev.github.io/Row-Template/compatibility/) for the research findings. +The three panels use three different template engines — Go `html/template`, Jinja2 and pongo2 — so every design is built once per panel, and each version is tested by rendering it with that panel's real engine. The installer detects which panel is on the server; on a server with more than one, it asks (or reads `RT_PANEL`). **Supported** means all seven capabilities are present on that panel — detect, install, activate, verify, backup, restore and uninstall — and each one is exercised by the test suite. See [Compatibility](https://iitzseridev.github.io/Row-Template/compatibility/) for the details of each panel. ## Architecture @@ -117,14 +121,14 @@ Choose a design during a fresh interactive install, set `RT_TEMPLATE` for a scri flowchart TB subgraph build ["Build and release"] direction LR - SRC["src/
runtime, styles, locales,
15 design layouts"] --> BUILD["tools/build.mjs"] - BUILD --> ART["One self-contained
HTML file per design"] + SRC["src/
runtime, styles, locales,
17 design layouts"] --> BUILD["tools/build.mjs"] + BUILD --> ART["One self-contained
HTML file per design,
per panel"] ART --> REL["tools/make-release.sh
tarball + SHA256SUMS"] end - subgraph host ["Your 3X-UI server"] + subgraph host ["Your panel server"] direction LR - INST["install.sh / row-template
verify checksum, stage, validate,
back up, activate"] --> DIR["/etc/3x-ui/
sub_templates/row-template"] - DIR -- "subThemeDir" --> XUI["3X-UI renders the page
with the subscriber's data"] + INST["install.sh / row-template
verify checksum, detect panel,
back up, activate, verify"] --> DIR["3X-UI: subThemeDir
PasarGuard: .env block
Rebecca: subscription settings"] + DIR --> XUI["The panel renders the page
with the subscriber's data"] end build -- "GitHub Releases" --> host host -- "serves the page" --> BROWSER["Subscriber's browser"] @@ -132,15 +136,15 @@ flowchart TB ``` - **One file per design.** `tools/build.mjs` inlines the shared runtime, the translations, the fonts, and the QR generator into a design's layout, and refuses a layout that is missing any hook the runtime needs. `tools/verify.mjs` then rejects an artifact that loads anything remote or carries a forbidden construct. -- **The panel does the rendering.** The page is a template: 3X-UI fills in the subscriber's data when it serves it, and the page then refreshes its status from the same panel. -- **The installer never edits 3X-UI.** It writes its own directory and changes one panel setting, `subThemeDir`, to point at it. +- **The panel does the rendering.** The page is a template: the panel fills in the subscriber's data when it serves it. For PasarGuard (Jinja2) and Rebecca (pongo2) each design is wrapped in a small prelude that maps the panel's own data onto the page and escapes every value. +- **The installer never patches your panel.** On 3X-UI it points `subThemeDir` at its own directory; on PasarGuard it places the page in the templates directory and appends one marked block to `.env`; on Rebecca it places the page and sets the page and directory fields of its subscription settings. Each change is snapshotted first and restored exactly if anything fails. | Path | What lives there | | ---- | ---------------- | | `src/` | The page's runtime, styles, and translations; each design in `src/templates//` | | `template/index.html` | The built Row page, committed | | `tools/` | Build, verification, release, and the Go fixture renderer | -| `installer/` | `install.sh`, the `row-template` command, and its management library | +| `installer/` | `install.sh`, the `row-template` command, its management library, and one adapter per panel in `installer/panels/` | | `tests/` | The test suites | | `docs/` | The documentation site; design records in [`docs/design/`](docs/design/README.md) | @@ -148,9 +152,9 @@ flowchart TB > **Recommended OS: Ubuntu 24.04 LTS (x86_64).** Other modern Linux distributions may work but have not had the same validation coverage. -**Requirements:** a server running 3X-UI **>= 3.6.0**, root access to it, and `curl`, `tar`, and `sha256sum` (present on virtually all Linux systems). Automatic activation also needs `sqlite3`. +**Requirements:** a server running 3X-UI **>= 3.6.0**, PasarGuard, or Rebecca **1.x**; root access to it; and `curl`, `tar`, and `sha256sum` (present on virtually all Linux systems). Automatic activation on 3X-UI and Rebecca also needs `sqlite3`. -Run as **root** on the server that hosts your 3X-UI panel: +Run as **root** on the server that hosts your panel: ```bash bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) @@ -160,7 +164,7 @@ The installer: 1. Downloads the latest stable release from GitHub. 2. Verifies its SHA-256 checksum (mandatory — no bypass). -3. Extracts it safely and installs to `/etc/3x-ui/sub_templates/row-template`. +3. Detects your panel, extracts the release safely and installs to `/etc/3x-ui/sub_templates/row-template` (3X-UI) or `/etc/row-template` (PasarGuard, Rebecca). 4. On a fresh install, offers the design chooser (Enter keeps Row). 5. Prompts for your branding (service name, support link, logo — all optional). 6. Generates and validates the page, then activates it in the panel where possible. @@ -171,6 +175,12 @@ To choose a design without the chooser, for example in a script: RT_TEMPLATE=editorial bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) ``` +On a server that runs more than one supported panel, the installer asks which one to serve; in a script, name it with `RT_PANEL` (`3xui`, `pasarguard` or `rebecca`): + +```bash +RT_PANEL=pasarguard bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) +``` + If you prefer not to pipe from the network, download the four release assets (`install.sh`, `manifest.txt`, `SHA256SUMS`, and `row-template-.tar.gz`) from the [Releases page](https://github.com/iitzSeriZdev/Row-Template/releases/latest) into one folder, verify the checksum yourself as described in [PROVENANCE.md](PROVENANCE.md), and point the installer at that folder: ```bash @@ -179,19 +189,39 @@ RT_RELEASE_DIR=/root/row-template-release bash /root/row-template-release/instal ### Activation -Row-Template installs to a directory that the panel serves as its subscription page: +An interactive install shows what activation will change and asks first. On PasarGuard and Rebecca, activation runs as a transaction: the panel's state is snapshotted, changed, verified, and — if any step fails — restored exactly. + +**3X-UI.** Row-Template installs to a directory that the panel serves as its subscription page: ``` /etc/3x-ui/sub_templates/row-template ``` -- **Automatic:** when `sqlite3` is available, Row-Template sets it for you. It briefly stops the panel service, writes the setting, starts the service again, and checks the value. An interactive install shows the current setting and asks first. +- **Automatic:** when `sqlite3` is available, Row-Template sets it for you. It briefly stops the panel service, writes the setting, starts the service again, and checks the value. - **Manual:** otherwise, open **Panel Settings → Subscription → Profile → Sub Theme Directory** and enter exactly: ``` /etc/3x-ui/sub_templates/row-template ``` +**PasarGuard.** The page is placed at `/var/lib/pasarguard/templates/row-template/index.html` (or inside your own `CUSTOM_TEMPLATES_DIRECTORY`, if you set one), and a marked block is appended to `/opt/pasarguard/.env`: + +``` +# >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# <<< row-template <<< +``` + +PasarGuard reads `.env` at start-up, so a running panel is restarted once. None of your own lines are edited; uninstall removes the block and returns `.env` to its exact previous bytes. An admin with their own subscription template, or the **disable subscription template** setting, still takes precedence — `row-template verify` tells you when either applies. + +Row-Template supports Rebecca **1.x**, the Go edition, which Rebecca publishes for its binary install (`rebecca-binary.sh`). Docker Hub's `rebeccapanel/rebecca` image is still the 0.0.x Python edition, which cannot render this page; the installer refuses it and changes nothing, and Rebecca's own `rebecca migrate-binary` moves a Docker install to 1.x. + +**Rebecca.** The page is placed at `/var/lib/rebecca/templates/row-template/index.html` (or inside your own custom templates directory), and Rebecca's subscription settings are set to `row-template/index.html`. Rebecca reads them on every request, so no restart is needed. + +- **Automatic** with the default SQLite database and `sqlite3` installed. +- **Manual** with MySQL/MariaDB (or without `sqlite3`): the page is still placed; in the Rebecca dashboard open **Settings → Subscription → Templates** and set **Subscription page template** to `row-template/index.html` and **Custom templates directory** to `/var/lib/rebecca/templates`. + ## Usage Run the manager with no arguments in a terminal to open the interactive menu: @@ -207,23 +237,24 @@ Or use a command directly: | `row-template config` | Change the service name, support link, or logo, then regenerate the page | | `row-template update` | Download, verify, and activate the latest stable release (checksum enforced) | | `row-template rollback` | Restore a previous version (`--auto` or `--to `) | -| `row-template verify` | Check the install, the panel wiring, and the live page (read-only) | -| `row-template version` | Show the installed, minimum-supported, and detected 3X-UI versions | -| `row-template uninstall` | Remove Row-Template and revert the panel to its built-in page | +| `row-template verify` | Check the install, the panel wiring, and the live page (as root, it also puts back missing or misplaced designs) | +| `row-template version` | Show the installed version and the panel it serves (on 3X-UI, also the minimum-supported and detected versions) | +| `row-template uninstall` | Remove Row-Template and return the panel to the page it had before | | `row-template help` | Show usage | Commands that change the system (`config`, `update`, `rollback`, `uninstall`) must run as root. - **Branding** is stored as data, never executed, and injected into the page as text. Leave a field blank for an unbranded page. The support link accepts only schemes a browser should open, such as `https://…`, `tg://…`, or `mailto:…`. - **Updates** come from the public stable channel. `row-template update` always applies the latest stable release, even the version you already run; the manager's **Update** compares versions first and asks before changing anything. If the release source is unreachable, nothing is changed and your installation is never treated as damaged. -- **Rollback** restores a previous version from a validated backup. The current version is snapshotted first, so a failed rollback can be recovered, and your branding is preserved. -- **Uninstall** removes Row-Template's files. It clears the panel's `subThemeDir` only if it points at Row-Template, so the panel falls back to its built-in page; your inbounds, clients, and certificates are not touched. +- **Updating from 1.1.0 or 1.2.x** takes one `row-template update`. 1.1.0's own updater copies only part of the new release, so the next `row-template`, `row-template config`, or `row-template verify` run as root first downloads the rest of that same release — every design, checksum-verified. Your design, branding, and panel wiring are kept. +- **Rollback** restores a previous version from a validated backup. The current version is snapshotted first, so a failed rollback can be recovered, and your branding is preserved. Backups record the panel they were made on and are never restored onto another; a backup from an older release that does not name its design restores as Row. +- **Uninstall** removes Row-Template's files and returns the panel to the page it had before: on 3X-UI it clears `subThemeDir` only if it points at Row-Template; on PasarGuard it removes its `.env` block and its page; on Rebecca it restores the two subscription settings it changed (leaving them alone if you have since chosen another page). Your users, inbounds, clients, nodes, and certificates are not touched. The [documentation](https://iitzseridev.github.io/Row-Template/) covers configuration, branding, and troubleshooting in more depth. ## Development -The pages are built from readable sources in `src/`. You need Node.js 22 or newer, and Go 1.22 or newer to run the tests. +The pages are built from readable sources in `src/`. You need Node.js 22 or newer; to run the tests, also Go 1.22 or newer and Python 3 with Jinja2 (`pip install jinja2`), which render the PasarGuard and Rebecca pages with those panels' real engines. ```bash npm run build # regenerate template/index.html from src/ @@ -238,7 +269,7 @@ The build is deterministic — the same sources always produce a byte-identical ## Testing -- **`npm test`** renders every design's fixture pages with the Go renderer, then runs the suites: the page's scripts, the build, every design's artifact, the release payload, and the installer — which runs the shipped shell library in real `bash` against throwaway fixtures. +- **`npm test`** renders every design's fixture pages with the Go renderer, then runs the suites: the page's scripts, the build, every design's artifact, the PasarGuard and Rebecca pages rendered by real Jinja2 and pongo2 (including hostile and malformed data), the release payload, and the installer — which runs the shipped shell library and every panel adapter in real `bash` against throwaway hosts laid out like each panel's official install. - **`npm run verify`** checks a built page against its safety gates, including: a whole document, every build marker substituted, everything inlined, no remote references, no forbidden constructs, intact translations, and no invisible characters in the sources. - **`npm run lint:sh`** fails on any ShellCheck error; `npm run lint:sh -- -S warning` shows the full report. - **The Docs workflow** builds the documentation site on every pull request that changes it. @@ -247,18 +278,17 @@ The build is deterministic — the same sources always produce a byte-identical Direction, not promises: -- **Row-Template 1.2.0** — the fifteen designs and the design chooser described above. -- **PasarGuard and Rebecca** — research. Page shells are built for both; live status needs a small runtime change or a reverse proxy, and that decision is deferred. See [Compatibility](https://iitzseridev.github.io/Row-Template/compatibility/). -- **Installing on more than one panel** — the installer groundwork (a panel interface, a transaction engine, a 3X-UI adapter, and a new backup format) is in place but not yet used by any command. +- **Row-Template 1.3.0** — PasarGuard and Rebecca support, and the Meter and Notebook designs, described above. +- **Live status on PasarGuard and Rebecca** — both serve it on a path suffix rather than `?format=info`; wiring it up needs a small runtime change, and that decision is deferred. See [Compatibility](https://iitzseridev.github.io/Row-Template/compatibility/). - **Custom templates** — a proposal for adding your own design: [`docs/design/CUSTOM-TEMPLATES-PROPOSAL.md`](docs/design/CUSTOM-TEMPLATES-PROPOSAL.md). ## Contributing Bug reports, translations, and documentation fixes are very welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request, and follow the [Code of Conduct](CODE_OF_CONDUCT.md). -**Bug reports:** open an issue at . Include your Row-Template version (`row-template version`), 3X-UI version, operating system and version, CPU architecture, the output of `row-template verify`, and clear steps to reproduce. +**Bug reports:** open an issue at . Include your Row-Template version (`row-template version`), your panel and its version, operating system and version, CPU architecture, the output of `row-template verify`, and clear steps to reproduce. -> **Do not include secrets.** Never paste subscription URLs, `subId` values, client UUIDs, panel usernames or passwords, cookies, tokens, the panel `webBasePath`, TLS keys, or real server addresses. Redact logs before sharing them. +> **Do not include secrets.** Never paste subscription URLs, `subId` values, client UUIDs, panel usernames or passwords, cookies, tokens, the panel `webBasePath`, the contents of `.env`, database URLs, TLS keys, or real server addresses. Redact logs before sharing them. ## Security diff --git a/README.ru.md b/README.ru.md index 6603a7b..9c8e6b2 100644 --- a/README.ru.md +++ b/README.ru.md @@ -7,7 +7,7 @@

- Аккуратная автономная страница подписки для панелей 3X-UI — пятнадцать дизайнов, каждый в одном HTML-файле, полностью white-label и без сторонних запросов со страницы, которую открывают ваши подписчики. + Аккуратная автономная страница подписки для панелей 3X-UI, PasarGuard и Rebecca — семнадцать дизайнов, каждый в одном HTML-файле, полностью white-label и без сторонних запросов со страницы, которую открывают ваши подписчики.

@@ -17,7 +17,7 @@

License Latest release - Panel + Panels Platform Documentation

@@ -34,21 +34,21 @@ ## Что это такое -3X-UI умеет показывать подписчикам собственную страницу вместо встроенной. Row-Template — такая страница: подписчик открывает ссылку на подписку и видит свой тариф, расход трафика, дату окончания и способы добавить подписку в своё приложение в одно касание. +3X-UI, PasarGuard и Rebecca умеют показывать подписчикам собственную страницу вместо встроенной. Row-Template — такая страница: подписчик открывает ссылку на подписку и видит свой тариф, расход трафика, дату окончания и способы добавить подписку в своё приложение в одно касание. -Каждый дизайн поставляется одним автономным HTML-файлом, в который встроены все стили, скрипты, шрифты и генератор QR-кодов. Одна команда устанавливает его рядом с панелью, направляет на него панель и даёт вам менеджер `row-template` для оформления, обновлений и отката. +Каждый дизайн поставляется одним автономным HTML-файлом, в который встроены все стили, скрипты, шрифты и генератор QR-кодов, а также версией на языке шаблонов каждой панели. Одна команда определяет вашу панель, устанавливает страницу рядом с ней, направляет на неё панель и даёт вам менеджер `row-template` для оформления, обновлений и отката. ## Почему Row-Template? - **Приватность по умолчанию.** Страница, которую открывают подписчики, не делает сторонних запросов. QR-коды генерируются прямо на странице, а ваше оформление вставляется как текст — никогда не выполняется и никуда не отправляется. - **Настоящий white-label.** Ваше название сервиса, ваша ссылка на поддержку, ваш логотип. Ничто на странице не указывает на Row-Template. -- **Пятнадцать дизайнов, каждый в одном файле.** Выберите вид, который подходит вашему сервису. У всех дизайнов одинаковые возможности, языки и проверки безопасности. +- **Семнадцать дизайнов, каждый в одном файле.** Выберите вид, который подходит вашему сервису. У всех дизайнов одинаковые возможности, языки и проверки безопасности — на каждой поддерживаемой панели. - **Сделано для ваших подписчиков.** Расход и срок действия в реальном времени, импорт в популярные приложения в одно касание и список отдельных конфигураций с поиском, чтобы добавить один сервер вручную. -- **Безопасен в эксплуатации.** Релизы с проверкой контрольной суммы, атомарная активация и откат одной командой. Row-Template никогда не патчит 3X-UI: единственная настройка панели, которую он меняет, — каталог страницы подписки (`subThemeDir`). +- **Безопасен в эксплуатации.** Релизы с проверкой контрольной суммы, транзакционная активация, которая точно восстанавливает панель при сбое любого шага, и откат одной командой. Row-Template никогда не патчит вашу панель: в 3X-UI он меняет одну настройку (`subThemeDir`), в PasarGuard добавляет один помеченный блок в `.env`, а в Rebecca задаёт два поля настроек подписки. ## Дизайны -Row-Template 1.2.0 поставляется с пятнадцатью дизайнами. Дизайн по умолчанию — Row. +Row-Template 1.3.0 поставляется с семнадцатью дизайнами. Дизайн по умолчанию — Row. @@ -72,6 +72,10 @@ Row-Template 1.2.0 поставляется с пятнадцатью дизай + + + +
Terminal Nova
Terminal Nova
Arcade Nova
Arcade Nova
Meter
Meter
Notebook
Notebook
Превью построены на демонстрационных данных самого проекта. Превью каждого дизайна для компьютера и телефона — в галерее шаблонов. @@ -82,7 +86,7 @@ Row-Template 1.2.0 поставляется с пятнадцатью дизай **Для ваших подписчиков** -- **Статус в реальном времени.** Состояние тарифа, израсходованный и оставшийся трафик и срок действия, которые обновляются из вашей панели, пока страница открыта на экране. +- **Статус в реальном времени.** Состояние тарифа, израсходованный и оставшийся трафик и срок действия, которые обновляются из вашей панели, пока страница открыта на экране (в 3X-UI; в PasarGuard и Rebecca страница показывает значения на момент открытия). - **Импорт в одно касание** в популярные приложения, сгруппированные по платформам: v2rayNG, Happ и sing-box на Android; Streisand, V2Box и Shadowrocket на iOS; Clash Verge Rev, Mihomo Party и v2rayN на Windows; Clash Verge Rev, Streisand и V2Box на macOS. - **Копирование и QR.** Скопируйте ссылку на подписку или отсканируйте её как QR-код, созданный на самой странице. - **Обозреватель конфигураций.** Каждый сервер в отдельной строке — с флагом страны или монограммой и меткой протокола (VLESS, VMess, Trojan, Shadowsocks, Hysteria/Hysteria2, WireGuard, AmneziaWG, Telegram MTProto), с QR-кодом и копированием для каждой конфигурации и поиском по длинным спискам. @@ -99,17 +103,17 @@ Row-Template 1.2.0 поставляется с пятнадцатью дизай - **Никаких сторонних запросов** со страницы: без CDN, без внешних сервисов QR и геолокации, без телеметрии. Статус в реальном времени приходит из вашей же панели. - **Обязательная проверка SHA-256** для каждой загрузки релиза, без возможности её пропустить. - **Атомарная активация.** Новая страница создаётся и проверяется до того, как заменит работающую, поэтому неудачный шаг никогда не оставляет сломанную страницу. -- **Осторожное обнаружение панели.** Если найденная база данных панели не является корректной базой SQLite, Row-Template отказывается её использовать, а не пытается угадать другую. +- **Осторожное обнаружение панели.** Панель считается установленной, только когда совпадают независимые признаки; частично установленная панель или база данных панели, не являющаяся корректной базой SQLite, отвергается, а не угадывается. ## Поддерживаемые панели | Панель | Статус | Примечания | | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ Поддерживается | Требуется версия **>= 3.6.0** | -| [PasarGuard](https://github.com/PasarGuard/panel) | 🔬 Исследование | Не поддерживается; установка не предусмотрена | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | 🔬 Исследование | Не поддерживается; установка не предусмотрена | +| [PasarGuard](https://github.com/PasarGuard/panel) | ✅ Поддерживается с 1.3.0 | Официальная установка в Docker или установка из исходников (`pasarguard.service`) | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ Поддерживается с 1.3.0 | Rebecca **1.x**, версия на Go (бинарная установка Rebecca). Автоматическая активация с SQLite и `sqlite3`; с MySQL/MariaDB — одна настройка в панели управления. Образ Docker всё ещё 0.0.x и отклоняется | -3X-UI — единственная поддерживаемая панель. PasarGuard и Rebecca используют другие шаблонизаторы (Jinja2 и pongo2); оболочка страницы каждого дизайна собирается для них и упаковывается в релиз для изучения, но установщик её не размещает, и инструкций по установке для них нет. Результаты исследования — в разделе [Совместимость](https://iitzseridev.github.io/Row-Template/compatibility/). +Три панели используют три разных шаблонизатора — Go `html/template`, Jinja2 и pongo2, — поэтому каждый дизайн собирается отдельно для каждой панели, и каждая версия проверяется отрисовкой настоящим движком этой панели. Установщик определяет, какая панель стоит на сервере; если их несколько, он спрашивает (или читает `RT_PANEL`). **Поддерживается** означает, что для этой панели есть все семь возможностей — обнаружение, установка, активация, проверка, резервная копия, восстановление и удаление, — и каждая из них покрыта тестами. Подробности по каждой панели — в разделе [Совместимость](https://iitzseridev.github.io/Row-Template/compatibility/). ## Архитектура @@ -117,14 +121,14 @@ Row-Template 1.2.0 поставляется с пятнадцатью дизай flowchart TB subgraph build ["Build and release"] direction LR - SRC["src/
runtime, styles, locales,
15 design layouts"] --> BUILD["tools/build.mjs"] - BUILD --> ART["One self-contained
HTML file per design"] + SRC["src/
runtime, styles, locales,
17 design layouts"] --> BUILD["tools/build.mjs"] + BUILD --> ART["One self-contained
HTML file per design,
per panel"] ART --> REL["tools/make-release.sh
tarball + SHA256SUMS"] end - subgraph host ["Your 3X-UI server"] + subgraph host ["Your panel server"] direction LR - INST["install.sh / row-template
verify checksum, stage, validate,
back up, activate"] --> DIR["/etc/3x-ui/
sub_templates/row-template"] - DIR -- "subThemeDir" --> XUI["3X-UI renders the page
with the subscriber's data"] + INST["install.sh / row-template
verify checksum, detect panel,
back up, activate, verify"] --> DIR["3X-UI: subThemeDir
PasarGuard: .env block
Rebecca: subscription settings"] + DIR --> XUI["The panel renders the page
with the subscriber's data"] end build -- "GitHub Releases" --> host host -- "serves the page" --> BROWSER["Subscriber's browser"] @@ -132,15 +136,15 @@ flowchart TB ``` - **Один файл на дизайн.** `tools/build.mjs` встраивает общий код, переводы, шрифты и генератор QR в макет дизайна и отклоняет макет, в котором нет хотя бы одной нужной коду точки привязки (hook). Затем `tools/verify.mjs` отклоняет файл, который загружает что-либо извне или содержит запрещённую конструкцию. -- **Страницу отрисовывает панель.** Страница — это шаблон: 3X-UI подставляет данные подписчика при выдаче, а затем страница обновляет свой статус из той же панели. -- **Установщик никогда не редактирует 3X-UI.** Он пишет в собственный каталог и меняет одну настройку панели, `subThemeDir`, чтобы она указывала на него. +- **Страницу отрисовывает панель.** Страница — это шаблон: панель подставляет данные подписчика при выдаче. Для PasarGuard (Jinja2) и Rebecca (pongo2) каждый дизайн обёрнут в небольшую преамбулу, которая сопоставляет собственные данные панели полям страницы и экранирует каждое значение. +- **Установщик никогда не патчит вашу панель.** В 3X-UI он направляет `subThemeDir` на свой каталог; в PasarGuard кладёт страницу в каталог шаблонов и дописывает в `.env` один помеченный блок; в Rebecca кладёт страницу и задаёт поля страницы и каталога в настройках подписки. Перед каждым изменением делается снимок, и при любом сбое всё точно восстанавливается. | Путь | Содержимое | | ---- | ---------------- | | `src/` | Код, стили и переводы страницы; каждый дизайн — в `src/templates//` | | `template/index.html` | Собранная страница Row, хранится в репозитории | | `tools/` | Сборка, проверка, релизы и рендерер фикстур на Go | -| `installer/` | `install.sh`, команда `row-template` и её библиотека управления | +| `installer/` | `install.sh`, команда `row-template`, её библиотека управления и по одному адаптеру на панель в `installer/panels/` | | `tests/` | Наборы тестов | | `docs/` | Сайт документации; проектные записи — в [`docs/design/`](docs/design/README.md) | @@ -148,9 +152,9 @@ flowchart TB > **Рекомендуемая ОС: Ubuntu 24.04 LTS (x86_64).** Другие современные дистрибутивы Linux могут работать, но не проходили такого же объёма проверок. -**Требования:** сервер с 3X-UI **>= 3.6.0**, root-доступ к нему и `curl`, `tar` и `sha256sum` (есть практически в любой системе Linux). Для автоматической активации также нужен `sqlite3`. +**Требования:** сервер с 3X-UI **>= 3.6.0**, PasarGuard или Rebecca **1.x**; root-доступ к нему; `curl`, `tar` и `sha256sum` (есть практически в любой системе Linux). Для автоматической активации в 3X-UI и Rebecca также нужен `sqlite3`. -Запустите от имени **root** на сервере, где работает ваша панель 3X-UI: +Запустите от имени **root** на сервере, где работает ваша панель: ```bash bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) @@ -160,7 +164,7 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d 1. Скачивает последний стабильный релиз с GitHub. 2. Проверяет его контрольную сумму SHA-256 (обязательно — без возможности обойти). -3. Безопасно распаковывает его и устанавливает в `/etc/3x-ui/sub_templates/row-template`. +3. Определяет вашу панель, безопасно распаковывает релиз и устанавливает его в `/etc/3x-ui/sub_templates/row-template` (3X-UI) или `/etc/row-template` (PasarGuard, Rebecca). 4. При новой установке предлагает выбрать дизайн (Enter оставляет Row). 5. Запрашивает ваше оформление (название сервиса, ссылка на поддержку, логотип — всё необязательно). 6. Создаёт и проверяет страницу, а затем, если возможно, активирует её в панели. @@ -171,6 +175,12 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d RT_TEMPLATE=editorial bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) ``` +На сервере, где работает несколько поддерживаемых панелей, установщик спрашивает, какую обслуживать; в скрипте укажите её через `RT_PANEL` (`3xui`, `pasarguard` или `rebecca`): + +```bash +RT_PANEL=pasarguard bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) +``` + Если вы не хотите запускать скрипт прямо из сети, скачайте четыре файла релиза (`install.sh`, `manifest.txt`, `SHA256SUMS` и `row-template-.tar.gz`) со [страницы релизов](https://github.com/iitzSeriZdev/Row-Template/releases/latest) в одну папку, проверьте контрольную сумму самостоятельно, как описано в [PROVENANCE.md](PROVENANCE.md), и укажите установщику эту папку: ```bash @@ -179,19 +189,39 @@ RT_RELEASE_DIR=/root/row-template-release bash /root/row-template-release/instal ### Активация -Row-Template устанавливается в каталог, который панель отдаёт как страницу подписки: +Интерактивная установка сначала показывает, что изменит активация, и спрашивает разрешения. В PasarGuard и Rebecca активация выполняется как транзакция: состояние панели сохраняется в снимок, изменение применяется и проверяется, а при сбое любого шага панель точно восстанавливается. + +**3X-UI.** Row-Template устанавливается в каталог, который панель отдаёт как страницу подписки: ``` /etc/3x-ui/sub_templates/row-template ``` -- **Автоматически:** если доступен `sqlite3`, Row-Template настраивает это за вас. Он ненадолго останавливает службу панели, записывает настройку, снова запускает службу и проверяет значение. Интерактивная установка сначала показывает текущее значение и спрашивает разрешения. +- **Автоматически:** если доступен `sqlite3`, Row-Template настраивает это за вас. Он ненадолго останавливает службу панели, записывает настройку, снова запускает службу и проверяет значение. - **Вручную:** иначе откройте **Panel Settings → Subscription → Profile → Sub Theme Directory** и введите в точности: ``` /etc/3x-ui/sub_templates/row-template ``` +**PasarGuard.** Страница размещается в `/var/lib/pasarguard/templates/row-template/index.html` (или в вашем `CUSTOM_TEMPLATES_DIRECTORY`, если он задан), а в `/opt/pasarguard/.env` дописывается помеченный блок: + +``` +# >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# <<< row-template <<< +``` + +PasarGuard читает `.env` при запуске, поэтому работающая панель перезапускается один раз. Ни одна ваша строка не редактируется; удаление убирает блок и возвращает `.env` точно к прежним байтам. Администратор с собственным шаблоном подписки или настройка **disable subscription template** по-прежнему имеют приоритет — `row-template verify` сообщит, если действует что-то из этого. + +Row-Template поддерживает Rebecca **1.x** — версию на Go, которую Rebecca публикует для бинарной установки (`rebecca-binary.sh`). Образ `rebeccapanel/rebecca` на Docker Hub всё ещё версии 0.0.x на Python, которая не может отрисовать эту страницу; установщик отклоняет её и ничего не меняет, а собственная команда Rebecca `rebecca migrate-binary` переводит установку Docker на 1.x. + +**Rebecca.** Страница размещается в `/var/lib/rebecca/templates/row-template/index.html` (или в вашем собственном каталоге шаблонов), а в настройках подписки Rebecca выбирается `row-template/index.html`. Rebecca читает эти настройки при каждом запросе, поэтому перезапуск не нужен. + +- **Автоматически** — с базой SQLite по умолчанию и установленным `sqlite3`. +- **Вручную** — с MySQL/MariaDB (или без `sqlite3`): страница всё равно размещается; в панели управления Rebecca откройте **Settings → Subscription → Templates** и задайте **Subscription page template** = `row-template/index.html` и **Custom templates directory** = `/var/lib/rebecca/templates`. + ## Использование Запустите менеджер без аргументов в терминале, чтобы открыть интерактивное меню: @@ -207,23 +237,24 @@ row-template | `row-template config` | Меняет название сервиса, ссылку на поддержку или логотип и заново создаёт страницу | | `row-template update` | Скачивает, проверяет и активирует последний стабильный релиз (проверка контрольной суммы обязательна) | | `row-template rollback` | Восстанавливает предыдущую версию (`--auto` или `--to `) | -| `row-template verify` | Проверяет установку, связь с панелью и работающую страницу (только чтение) | -| `row-template version` | Показывает установленную, минимально поддерживаемую и обнаруженную версии 3X-UI | -| `row-template uninstall` | Удаляет Row-Template и возвращает панели встроенную страницу | +| `row-template verify` | Проверяет установку, связь с панелью и работающую страницу (от root также возвращает на место отсутствующие или перемещённые дизайны) | +| `row-template version` | Показывает установленную версию и обслуживаемую панель (в 3X-UI — также минимально поддерживаемую и обнаруженную версии) | +| `row-template uninstall` | Удаляет Row-Template и возвращает панели страницу, которая была до него | | `row-template help` | Показывает справку | Команды, изменяющие систему (`config`, `update`, `rollback`, `uninstall`), нужно запускать от имени root. - **Оформление** хранится как данные, никогда не выполняется и вставляется в страницу как текст. Оставьте поле пустым, чтобы получить страницу без брендинга. Ссылка на поддержку принимает только схемы, которые браузер должен открывать, например `https://…`, `tg://…` или `mailto:…`. - **Обновления** берутся из публичного стабильного канала. `row-template update` всегда применяет последний стабильный релиз, даже если он уже установлен; пункт **Update** в менеджере сначала сравнивает версии и спрашивает перед любым изменением. Если источник релизов недоступен, ничего не меняется, и установка никогда не считается повреждённой. -- **Откат** восстанавливает предыдущую версию из проверенной резервной копии. Сначала делается снимок (snapshot) текущей версии, поэтому неудачный откат можно исправить, а ваше оформление сохраняется. -- **Удаление** стирает файлы Row-Template. Настройку `subThemeDir` панели оно очищает, только если та указывает на Row-Template, и панель возвращается к встроенной странице; ваши inbound'ы, клиенты и сертификаты не затрагиваются. +- **Обновление с 1.1.0 или 1.2.x** выполняется одним запуском `row-template update`. Механизм обновления самой версии 1.1.0 копирует лишь часть нового релиза, поэтому следующий запуск `row-template`, `row-template config` или `row-template verify` от root сначала загружает остальную часть того же релиза — все дизайны, с проверкой контрольных сумм. Ваш дизайн, оформление и подключение к панели сохраняются. +- **Откат** восстанавливает предыдущую версию из проверенной резервной копии. Сначала делается снимок (snapshot) текущей версии, поэтому неудачный откат можно исправить, а ваше оформление сохраняется. Резервные копии запоминают панель, на которой созданы, и никогда не восстанавливаются на другую; копия из старого релиза, в которой не записан дизайн, восстанавливается как Row. +- **Удаление** стирает файлы Row-Template и возвращает панели страницу, которая была до него: в 3X-UI очищает `subThemeDir`, только если та указывает на Row-Template; в PasarGuard убирает свой блок из `.env` и свою страницу; в Rebecca восстанавливает две изменённые настройки подписки (и не трогает их, если вы с тех пор выбрали другую страницу). Ваши пользователи, inbound'ы, клиенты, ноды и сертификаты не затрагиваются. [Документация](https://iitzseridev.github.io/Row-Template/) подробнее описывает настройку, оформление и устранение неполадок. ## Разработка -Страницы собираются из читаемых исходников в `src/`. Нужен Node.js 22 или новее, а для запуска тестов — Go 1.22 или новее. +Страницы собираются из читаемых исходников в `src/`. Нужен Node.js 22 или новее; для запуска тестов — также Go 1.22 или новее и Python 3 с Jinja2 (`pip install jinja2`), которые отрисовывают страницы PasarGuard и Rebecca настоящими движками этих панелей. ```bash npm run build # regenerate template/index.html from src/ @@ -238,7 +269,7 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 ## Тестирование -- **`npm test`** сначала создаёт страницы фикстур всех дизайнов рендерером на Go, а затем запускает наборы тестов: скрипты страницы, сборку, итоговый файл каждого дизайна, содержимое релиза и установщик — его опубликованная shell-библиотека выполняется в настоящем `bash` на временных фикстурах. +- **`npm test`** сначала создаёт страницы фикстур всех дизайнов рендерером на Go, а затем запускает наборы тестов: скрипты страницы, сборку, итоговый файл каждого дизайна, страницы PasarGuard и Rebecca, отрисованные настоящими Jinja2 и pongo2 (в том числе с враждебными и повреждёнными данными), содержимое релиза и установщик — его опубликованная shell-библиотека и адаптер каждой панели выполняются в настоящем `bash` на временных хостах, устроенных как официальная установка каждой панели. - **`npm run verify`** проверяет собранную страницу по её правилам безопасности, в том числе: цельный документ, замена всех маркеров сборки, всё встроено, нет внешних ссылок, нет запрещённых конструкций, целые переводы и отсутствие невидимых символов в исходниках. - **`npm run lint:sh`** завершается ошибкой при любой ошибке ShellCheck; `npm run lint:sh -- -S warning` показывает полный отчёт. - **Workflow Docs** собирает сайт документации в каждом pull request, который его меняет. @@ -247,18 +278,17 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 Направление, а не обещания: -- **Row-Template 1.2.0** — пятнадцать дизайнов и выбор дизайна, описанные выше. -- **PasarGuard и Rebecca** — исследование. Оболочки страниц для обеих собраны; для статуса в реальном времени нужно небольшое изменение в коде или обратный прокси (reverse proxy), и это решение отложено. См. [Совместимость](https://iitzseridev.github.io/Row-Template/compatibility/). -- **Установка на несколько панелей** — основа установщика (интерфейс панели, движок транзакций, адаптер 3X-UI и новый формат резервных копий) готова, но пока не используется ни одной командой. +- **Row-Template 1.3.0** — поддержка PasarGuard и Rebecca и дизайны Meter и Notebook, описанные выше. +- **Статус в реальном времени в PasarGuard и Rebecca** — обе отдают его по суффиксу пути, а не через `?format=info`; для подключения нужно небольшое изменение в коде, и это решение отложено. См. [Совместимость](https://iitzseridev.github.io/Row-Template/compatibility/). - **Собственные шаблоны** — предложение о добавлении своего дизайна: [`docs/design/CUSTOM-TEMPLATES-PROPOSAL.md`](docs/design/CUSTOM-TEMPLATES-PROPOSAL.md). ## Участие в проекте Сообщения об ошибках, переводы и исправления документации очень приветствуются. Прочитайте [CONTRIBUTING.md](CONTRIBUTING.md), прежде чем открывать pull request, и соблюдайте [Кодекс поведения](CODE_OF_CONDUCT.md). -**Сообщения об ошибках:** создайте issue на . Укажите версию Row-Template (`row-template version`), версию 3X-UI, операционную систему и её версию, архитектуру процессора, вывод `row-template verify` и чёткие шаги для воспроизведения. +**Сообщения об ошибках:** создайте issue на . Укажите версию Row-Template (`row-template version`), вашу панель и её версию, операционную систему и её версию, архитектуру процессора, вывод `row-template verify` и чёткие шаги для воспроизведения. -> **Не указывайте секретные данные.** Никогда не вставляйте URL подписок, значения `subId`, UUID клиентов, имена пользователей и пароли панели, cookie, токены, панельный `webBasePath`, ключи TLS или реальные адреса серверов. Скрывайте конфиденциальные данные в логах перед тем, как ими делиться. +> **Не указывайте секретные данные.** Никогда не вставляйте URL подписок, значения `subId`, UUID клиентов, имена пользователей и пароли панели, cookie, токены, панельный `webBasePath`, содержимое `.env`, URL баз данных, ключи TLS или реальные адреса серверов. Скрывайте конфиденциальные данные в логах перед тем, как ими делиться. ## Безопасность diff --git a/README.zh-CN.md b/README.zh-CN.md index 5506851..865597d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@

- 为 3X-UI 面板打造的精致、自包含的订阅页面 —— 十五种设计,每种都是一个 HTML 文件,完全白标(white-label),订阅者打开的页面不会向任何第三方发出请求。 + 为 3X-UI、PasarGuard 和 Rebecca 面板打造的精致、自包含的订阅页面 —— 十七种设计,每种都是一个 HTML 文件,完全白标(white-label),订阅者打开的页面不会向任何第三方发出请求。

@@ -16,7 +16,7 @@

License Latest release - Panel + Panels Platform Documentation

@@ -33,21 +33,21 @@ ## 这是什么 -3X-UI 可以向订阅者展示自定义页面来代替内置页面。Row-Template 就是这样一个页面:订阅者打开订阅链接,就能看到自己的套餐、用量和到期日期,以及一键把订阅添加到所用应用的方式。 +3X-UI、PasarGuard 和 Rebecca 都可以向订阅者展示自定义页面来代替内置页面。Row-Template 就是这样一个页面:订阅者打开订阅链接,就能看到自己的套餐、用量和到期日期,以及一键把订阅添加到所用应用的方式。 -每种设计都以一个自包含的 HTML 文件提供,所有样式、脚本、字体和二维码生成器都已内联其中。一条命令即可把它安装到面板旁边、让面板指向它,并为你提供 `row-template` 管理器,用于品牌设置、更新和回滚。 +每种设计都以一个自包含的 HTML 文件提供,所有样式、脚本、字体和二维码生成器都已内联其中,并为每个面板提供一个使用该面板自身模板语言的版本。一条命令即可检测你的面板、把页面安装到面板旁边、让面板指向它,并为你提供 `row-template` 管理器,用于品牌设置、更新和回滚。 ## 为什么选择 Row-Template? - **隐私优先。** 订阅者打开的页面不会向任何第三方发出请求。二维码在页面内生成,你的品牌信息以文本形式注入 —— 从不执行,也从不发送到任何地方。 - **真正的白标。** 你的服务名称、你的支持链接、你的徽标。所呈现的页面上没有任何内容标明 Row-Template。 -- **十五种设计,每种一个文件。** 选择适合你服务的外观。所有设计共享相同的功能、语言和安全检查。 +- **十七种设计,每种一个文件。** 选择适合你服务的外观。所有设计在每个受支持的面板上都共享相同的功能、语言和安全检查。 - **为你的订阅者而设计。** 实时显示用量和到期时间,一键导入常用应用,以及可搜索的单独配置列表,便于手动添加单个服务器。 -- **运维安全。** 经校验和验证的发布、原子化激活以及一条命令即可回滚。它从不修补 3X-UI:它唯一会修改的面板设置是订阅页面目录(`subThemeDir`)。 +- **运维安全。** 经校验和验证的发布、任何一步失败都会把面板精确恢复原状的事务式激活,以及一条命令即可回滚。它从不修补你的面板:在 3X-UI 上只修改一项设置(`subThemeDir`),在 PasarGuard 上向 `.env` 追加一个带标记的块,在 Rebecca 上设置订阅设置中的两个字段。 ## 设计 -Row-Template 1.2.0 提供十五种设计,默认设计为 Row。 +Row-Template 1.3.0 提供十七种设计,默认设计为 Row。 @@ -71,6 +71,10 @@ Row-Template 1.2.0 提供十五种设计,默认设计为 Row。 + + + +
Terminal Nova
Terminal Nova
Arcade Nova
Arcade Nova
Meter
Meter
Notebook
Notebook
预览图使用项目自带的示例数据渲染。每种设计的桌面端和移动端预览见模板画廊。 @@ -81,7 +85,7 @@ Row-Template 1.2.0 提供十五种设计,默认设计为 Row。 **面向你的订阅者** -- **实时状态。** 套餐状态、已用和剩余流量以及到期时间,在页面可见期间从你的面板刷新。 +- **实时状态。** 套餐状态、已用和剩余流量以及到期时间,在页面可见期间从你的面板刷新(3X-UI;在 PasarGuard 和 Rebecca 上,页面显示打开时的数值)。 - **一键导入**常用应用,按平台分组:Android 上的 v2rayNG、Happ 和 sing-box;iOS 上的 Streisand、V2Box 和 Shadowrocket;Windows 上的 Clash Verge Rev、Mihomo Party 和 v2rayN;macOS 上的 Clash Verge Rev、Streisand 和 V2Box。 - **复制与二维码。** 复制订阅链接,或扫描在页面内生成的二维码。 - **配置浏览器。** 每个服务器单独一行,带有国家旗帜或首字母徽章(monogram)以及协议标签(VLESS、VMess、Trojan、Shadowsocks、Hysteria/Hysteria2、WireGuard、AmneziaWG、Telegram MTProto),每个配置都可查看二维码和复制,长列表支持搜索。 @@ -98,17 +102,17 @@ Row-Template 1.2.0 提供十五种设计,默认设计为 Row。 - 所呈现的页面**不向第三方发出任何请求**:没有 CDN,没有外部二维码或地理定位服务,没有遥测。实时状态来自你自己的面板。 - 每次下载发布版本都**强制进行 SHA-256 校验**,且没有跳过的选项。 - **原子化激活。** 新页面在替换当前页面之前先生成并通过验证,因此失败的步骤绝不会让损坏的页面上线。 -- **谨慎的面板检测。** 如果 Row-Template 找到的面板数据库不是有效的 SQLite 数据库,它会拒绝使用,而不是去猜测另一个数据库。 +- **谨慎的面板检测。** 只有在多个独立迹象一致时才认为面板已安装;只安装了一半的面板,或不是有效 SQLite 数据库的面板数据库,都会被拒绝,而不是去猜测。 ## 支持的面板 | 面板 | 状态 | 说明 | | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ 已支持 | 需要 **>= 3.6.0** 版本 | -| [PasarGuard](https://github.com/PasarGuard/panel) | 🔬 研究中 | 不受支持;没有安装途径 | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | 🔬 研究中 | 不受支持;没有安装途径 | +| [PasarGuard](https://github.com/PasarGuard/panel) | ✅ 自 1.3.0 起支持 | 官方 Docker 安装或源码安装(`pasarguard.service`) | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ 自 1.3.0 起支持 | Rebecca **1.x**,即 Go 版本(Rebecca 的二进制安装)。使用 SQLite 和 `sqlite3` 时自动激活;使用 MySQL/MariaDB 时需在控制台中填写一项设置。Docker 镜像仍是 0.0.x,会被拒绝 | -3X-UI 是唯一受支持的面板。PasarGuard 和 Rebecca 使用不同的模板引擎(Jinja2 和 pongo2);每种设计都会为它们构建页面外壳并打包进发布版本以供研究,但安装程序不会部署它,也没有针对它们的安装说明。研究结果见[兼容性](https://iitzseridev.github.io/Row-Template/compatibility/)。 +三个面板使用三种不同的模板引擎 —— Go `html/template`、Jinja2 和 pongo2 —— 因此每种设计都会为每个面板分别构建,并用该面板真实的引擎渲染来测试每个版本。安装程序会检测服务器上是哪一个面板;如果有多个,它会询问你(或读取 `RT_PANEL`)。**支持**意味着该面板具备全部七项能力 —— 检测、安装、激活、校验、备份、还原和卸载 —— 并且每一项都由测试套件覆盖。各面板的详细信息见[兼容性](https://iitzseridev.github.io/Row-Template/compatibility/)。 ## 架构 @@ -116,14 +120,14 @@ Row-Template 1.2.0 提供十五种设计,默认设计为 Row。 flowchart TB subgraph build ["Build and release"] direction LR - SRC["src/
runtime, styles, locales,
15 design layouts"] --> BUILD["tools/build.mjs"] - BUILD --> ART["One self-contained
HTML file per design"] + SRC["src/
runtime, styles, locales,
17 design layouts"] --> BUILD["tools/build.mjs"] + BUILD --> ART["One self-contained
HTML file per design,
per panel"] ART --> REL["tools/make-release.sh
tarball + SHA256SUMS"] end - subgraph host ["Your 3X-UI server"] + subgraph host ["Your panel server"] direction LR - INST["install.sh / row-template
verify checksum, stage, validate,
back up, activate"] --> DIR["/etc/3x-ui/
sub_templates/row-template"] - DIR -- "subThemeDir" --> XUI["3X-UI renders the page
with the subscriber's data"] + INST["install.sh / row-template
verify checksum, detect panel,
back up, activate, verify"] --> DIR["3X-UI: subThemeDir
PasarGuard: .env block
Rebecca: subscription settings"] + DIR --> XUI["The panel renders the page
with the subscriber's data"] end build -- "GitHub Releases" --> host host -- "serves the page" --> BROWSER["Subscriber's browser"] @@ -131,15 +135,15 @@ flowchart TB ``` - **每种设计一个文件。** `tools/build.mjs` 将共享的运行时代码、翻译、字体和二维码生成器内联到设计的布局中,并拒绝缺少任何运行时所需钩子(hook)的布局。随后 `tools/verify.mjs` 会拒绝任何加载远程资源或包含禁用结构的文件。 -- **由面板负责渲染。** 页面是一个模板:3X-UI 在提供页面时填入订阅者的数据,之后页面再从同一面板刷新状态。 -- **安装程序从不修改 3X-UI。** 它只写入自己的目录,并修改一项面板设置 `subThemeDir`,使其指向该目录。 +- **由面板负责渲染。** 页面是一个模板:面板在提供页面时填入订阅者的数据。对于 PasarGuard(Jinja2)和 Rebecca(pongo2),每种设计都包在一段小的前导代码中,它把面板自身的数据映射到页面字段并对每个值进行转义。 +- **安装程序从不修补你的面板。** 在 3X-UI 上,它让 `subThemeDir` 指向自己的目录;在 PasarGuard 上,它把页面放入模板目录,并向 `.env` 追加一个带标记的块;在 Rebecca 上,它放置页面并设置订阅设置中的页面和目录字段。每次修改前都会先做快照,任何一步失败都会精确恢复。 | 路径 | 内容 | | ---- | ---------------- | | `src/` | 页面的运行时代码、样式和翻译;每种设计位于 `src/templates//` | | `template/index.html` | 构建好的 Row 页面,已提交到仓库 | | `tools/` | 构建、验证、发布以及 Go 编写的 fixture 渲染器 | -| `installer/` | `install.sh`、`row-template` 命令及其管理库 | +| `installer/` | `install.sh`、`row-template` 命令、其管理库,以及 `installer/panels/` 中每个面板各一个的适配器 | | `tests/` | 测试套件 | | `docs/` | 文档站点;设计记录位于 [`docs/design/`](docs/design/README.md) | @@ -147,9 +151,9 @@ flowchart TB > **推荐操作系统:Ubuntu 24.04 LTS (x86_64)。** 其他较新的 Linux 发行版或许也能运行,但未经过同等程度的验证覆盖。 -**环境要求:** 运行 3X-UI **>= 3.6.0** 的服务器、该服务器的 root 权限,以及 `curl`、`tar` 和 `sha256sum`(几乎所有 Linux 系统都自带)。自动激活还需要 `sqlite3`。 +**环境要求:** 运行 3X-UI **>= 3.6.0**、PasarGuard 或 Rebecca **1.x** 的服务器;该服务器的 root 权限;以及 `curl`、`tar` 和 `sha256sum`(几乎所有 Linux 系统都自带)。在 3X-UI 和 Rebecca 上自动激活还需要 `sqlite3`。 -在托管 3X-UI 面板的服务器上以 **root** 身份运行: +在托管你的面板的服务器上以 **root** 身份运行: ```bash bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) @@ -159,7 +163,7 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d 1. 从 GitHub 下载最新的稳定版本。 2. 校验其 SHA-256 校验和(强制 —— 无法绕过)。 -3. 安全地解压并安装到 `/etc/3x-ui/sub_templates/row-template`。 +3. 检测你的面板,安全地解压发布包并安装到 `/etc/3x-ui/sub_templates/row-template`(3X-UI)或 `/etc/row-template`(PasarGuard、Rebecca)。 4. 全新安装时显示设计选择器(按 Enter 保留 Row)。 5. 提示你设置品牌信息(服务名称、支持链接、徽标 —— 均为可选)。 6. 生成并验证页面,然后在可能的情况下在面板中激活它。 @@ -170,6 +174,12 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d RT_TEMPLATE=editorial bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) ``` +在运行多个受支持面板的服务器上,安装程序会询问要为哪一个提供页面;在脚本中,可用 `RT_PANEL`(`3xui`、`pasarguard` 或 `rebecca`)指定: + +```bash +RT_PANEL=pasarguard bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) +``` + 如果你不希望直接从网络通过管道执行,可以从 [Releases 页面](https://github.com/iitzSeriZdev/Row-Template/releases/latest)将四个发布文件(`install.sh`、`manifest.txt`、`SHA256SUMS` 和 `row-template-.tar.gz`)下载到同一个文件夹,按照 [PROVENANCE.md](PROVENANCE.md) 中的说明自行校验校验和,然后让安装程序使用该文件夹: ```bash @@ -178,19 +188,39 @@ RT_RELEASE_DIR=/root/row-template-release bash /root/row-template-release/instal ### 激活 -Row-Template 安装在一个由面板作为订阅页面提供的目录中: +交互式安装会先说明激活将修改什么,并征求你的同意。在 PasarGuard 和 Rebecca 上,激活以事务方式进行:先为面板状态做快照,再应用并验证修改;任何一步失败,面板都会被精确恢复。 + +**3X-UI。** Row-Template 安装在一个由面板作为订阅页面提供的目录中: ``` /etc/3x-ui/sub_templates/row-template ``` -- **自动:** 当 `sqlite3` 可用时,Row-Template 会替你完成设置。它会短暂停止面板服务、写入设置、重新启动服务并核对该值。交互式安装会先显示当前设置并征求你的同意。 +- **自动:** 当 `sqlite3` 可用时,Row-Template 会替你完成设置。它会短暂停止面板服务、写入设置、重新启动服务并核对该值。 - **手动:** 否则,请打开 **Panel Settings → Subscription → Profile → Sub Theme Directory** 并准确输入: ``` /etc/3x-ui/sub_templates/row-template ``` +**PasarGuard。** 页面放在 `/var/lib/pasarguard/templates/row-template/index.html`(如果你设置了自己的 `CUSTOM_TEMPLATES_DIRECTORY`,则放在其中),并向 `/opt/pasarguard/.env` 追加一个带标记的块: + +``` +# >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# <<< row-template <<< +``` + +PasarGuard 在启动时读取 `.env`,因此正在运行的面板会重启一次。你自己的任何一行都不会被修改;卸载会删除该块,并把 `.env` 精确恢复为之前的字节。拥有自己订阅模板的管理员,或 **disable subscription template** 设置,仍然优先 —— 如果其中之一生效,`row-template verify` 会告诉你。 + +Row-Template 支持 Rebecca **1.x**,即 Rebecca 为其二进制安装(`rebecca-binary.sh`)发布的 Go 版本。Docker Hub 上的 `rebeccapanel/rebecca` 镜像仍是 0.0.x 的 Python 版本,无法渲染此页面;安装程序会拒绝它且不做任何更改,而 Rebecca 自带的 `rebecca migrate-binary` 可以把 Docker 安装迁移到 1.x。 + +**Rebecca。** 页面放在 `/var/lib/rebecca/templates/row-template/index.html`(或你自己的自定义模板目录中),并把 Rebecca 的订阅设置设为 `row-template/index.html`。Rebecca 在每次请求时读取这些设置,因此无需重启。 + +- **自动:** 使用默认的 SQLite 数据库并已安装 `sqlite3` 时。 +- **手动:** 使用 MySQL/MariaDB(或没有 `sqlite3`)时:页面仍会放好;在 Rebecca 控制台中打开 **Settings → Subscription → Templates**,把 **Subscription page template** 设为 `row-template/index.html`,把 **Custom templates directory** 设为 `/var/lib/rebecca/templates`。 + ## 使用 在终端中不带参数运行管理器以打开交互式菜单: @@ -206,23 +236,24 @@ row-template | `row-template config` | 更改服务名称、支持链接或徽标,然后重新生成页面 | | `row-template update` | 下载、校验并激活最新的稳定版本(强制校验校验和) | | `row-template rollback` | 恢复到之前的版本(`--auto` 或 `--to `) | -| `row-template verify` | 检查安装、面板连接和当前页面(只读) | -| `row-template version` | 显示已安装版本、最低支持版本以及检测到的 3X-UI 版本 | -| `row-template uninstall` | 移除 Row-Template 并让面板恢复其内置页面 | +| `row-template verify` | 检查安装、面板连接和当前页面(以 root 运行时还会补回缺失或放错位置的设计) | +| `row-template version` | 显示已安装版本及其服务的面板(在 3X-UI 上还显示最低支持版本和检测到的版本) | +| `row-template uninstall` | 移除 Row-Template 并让面板恢复之前使用的页面 | | `row-template help` | 显示用法 | 会修改系统的命令(`config`、`update`、`rollback`、`uninstall`)必须以 root 身份运行。 - **品牌信息**以数据形式存储,从不执行,并以文本形式注入页面。将某个字段留空即可得到无品牌的页面。支持链接只接受浏览器应当打开的协议,例如 `https://…`、`tg://…` 或 `mailto:…`。 - **更新**来自公共稳定通道。`row-template update` 总是应用最新的稳定版本,即使你已安装的就是该版本;管理器中的 **Update** 会先比较版本,并在做出任何更改前询问。如果无法访问发布源,则不会做任何更改,你的安装也绝不会因此被视为已损坏。 -- **回滚**会从经过验证的备份中恢复之前的版本。系统会先为当前版本创建快照(snapshot),因此失败的回滚也可以恢复,且你的品牌配置会被保留。 -- **卸载**会移除 Row-Template 的文件。只有当面板的 `subThemeDir` 指向 Row-Template 时才会将其清除,使面板恢复内置页面;你的入站(inbound)、客户端和证书都不会受到影响。 +- **从 1.1.0 或 1.2.x 更新**只需运行一次 `row-template update`。1.1.0 自带的更新程序只会复制新版本的一部分,因此下一次以 root 运行 `row-template`、`row-template config` 或 `row-template verify` 时,会先下载同一版本的其余部分——所有设计,并校验 checksum。你的设计、品牌配置和面板连接都会保留。 +- **回滚**会从经过验证的备份中恢复之前的版本。系统会先为当前版本创建快照(snapshot),因此失败的回滚也可以恢复,且你的品牌配置会被保留。备份会记录其所在的面板,绝不会恢复到另一个面板上;来自旧版本、未记录设计名称的备份会按 Row 恢复。 +- **卸载**会移除 Row-Template 的文件,并让面板恢复之前使用的页面:在 3X-UI 上,只有当 `subThemeDir` 指向 Row-Template 时才会将其清除;在 PasarGuard 上,删除它在 `.env` 中的块和它的页面;在 Rebecca 上,恢复它修改过的两项订阅设置(如果你此后已选择了其他页面,则不做改动)。你的用户、入站(inbound)、客户端、节点和证书都不会受到影响。 [文档](https://iitzseridev.github.io/Row-Template/)更详细地介绍了配置、品牌设置和故障排查。 ## 开发 -页面由 `src/` 中可读的源代码构建而成。你需要 Node.js 22 或更高版本,运行测试还需要 Go 1.22 或更高版本。 +页面由 `src/` 中可读的源代码构建而成。你需要 Node.js 22 或更高版本;运行测试还需要 Go 1.22 或更高版本,以及带 Jinja2 的 Python 3(`pip install jinja2`),它们用这两个面板真实的引擎渲染 PasarGuard 和 Rebecca 页面。 ```bash npm run build # regenerate template/index.html from src/ @@ -237,7 +268,7 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 ## 测试 -- **`npm test`** 先用 Go 渲染器生成所有设计的 fixture 页面,然后运行各测试套件:页面脚本、构建、每种设计的最终文件、发布包内容以及安装程序 —— 其发布的 shell 库会在真实的 `bash` 中针对临时 fixture 运行。 +- **`npm test`** 先用 Go 渲染器生成所有设计的 fixture 页面,然后运行各测试套件:页面脚本、构建、每种设计的最终文件、由真实 Jinja2 和 pongo2 渲染的 PasarGuard 和 Rebecca 页面(包括恶意和畸形数据)、发布包内容以及安装程序 —— 其发布的 shell 库和每个面板的适配器会在真实的 `bash` 中,针对按各面板官方安装方式布置的临时主机运行。 - **`npm run verify`** 按照安全关卡检查构建好的页面,包括:完整的文档、所有构建标记均已替换、所有内容均已内联、没有远程引用、没有禁用结构、翻译完整,以及源代码中没有不可见字符。 - **`npm run lint:sh`** 遇到任何 ShellCheck 错误即失败;`npm run lint:sh -- -S warning` 会显示完整报告。 - **Docs 工作流**会在每个修改文档站点的 pull request 中构建该站点。 @@ -246,18 +277,17 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 这是方向,而非承诺: -- **Row-Template 1.2.0** —— 上文介绍的十五种设计和设计选择器。 -- **PasarGuard 和 Rebecca** —— 研究中。两者的页面外壳均已构建;实时状态需要对运行时代码做一处小改动或使用反向代理(reverse proxy),这一决定已推迟。参见[兼容性](https://iitzseridev.github.io/Row-Template/compatibility/)。 -- **在多个面板上安装** —— 安装程序的基础设施(面板接口、事务引擎、3X-UI 适配器和新的备份格式)已经就绪,但尚未被任何命令使用。 +- **Row-Template 1.3.0** —— 上文介绍的 PasarGuard 和 Rebecca 支持,以及 Meter 和 Notebook 设计。 +- **PasarGuard 和 Rebecca 上的实时状态** —— 两者都通过路径后缀而不是 `?format=info` 提供它;接入需要对运行时代码做一处小改动,这一决定已推迟。参见[兼容性](https://iitzseridev.github.io/Row-Template/compatibility/)。 - **自定义模板** —— 关于添加你自己设计的提案:[`docs/design/CUSTOM-TEMPLATES-PROPOSAL.md`](docs/design/CUSTOM-TEMPLATES-PROPOSAL.md)。 ## 参与贡献 非常欢迎问题反馈、翻译和文档修正。在提交 pull request 之前请阅读 [CONTRIBUTING.md](CONTRIBUTING.md),并遵守[行为准则](CODE_OF_CONDUCT.md)。 -**问题反馈:** 请在 提交 issue。请附上你的 Row-Template 版本(`row-template version`)、3X-UI 版本、操作系统及其版本、CPU 架构、`row-template verify` 的输出,以及清晰的复现步骤。 +**问题反馈:** 请在 提交 issue。请附上你的 Row-Template 版本(`row-template version`)、你的面板及其版本、操作系统及其版本、CPU 架构、`row-template verify` 的输出,以及清晰的复现步骤。 -> **请勿包含机密信息。** 切勿粘贴订阅 URL、`subId` 值、客户端 UUID、面板用户名或密码、Cookie、令牌、面板的 `webBasePath`、TLS 密钥或真实的服务器地址。分享日志前请先对其做脱敏处理。 +> **请勿包含机密信息。** 切勿粘贴订阅 URL、`subId` 值、客户端 UUID、面板用户名或密码、Cookie、令牌、面板的 `webBasePath`、`.env` 的内容、数据库 URL、TLS 密钥或真实的服务器地址。分享日志前请先对其做脱敏处理。 ## 安全 diff --git a/VERSION b/VERSION index 26aaba0..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/docs/design/CUSTOM-TEMPLATE-GUIDELINES.md b/docs/design/CUSTOM-TEMPLATE-GUIDELINES.md index b68aa61..7fcba4b 100644 --- a/docs/design/CUSTOM-TEMPLATE-GUIDELINES.md +++ b/docs/design/CUSTOM-TEMPLATE-GUIDELINES.md @@ -1,7 +1,7 @@ # Custom Template Guidelines **Status:** binding contract for every future custom template. -**Applies to:** any template added to this repository that is not one of the fifteen +**Applies to:** any template added to this repository that is not one of the seventeen frozen core designs. **Companion documents:** `CUSTOM-TEMPLATES-PROPOSAL.md` (the design rationale and phased plan), `CONTRIBUTING.md` (the general contribution process), @@ -85,14 +85,14 @@ behaviour and defaults a custom entry to unlocked"*. The gap between 15 and 200 is intentional: it leaves room for future core designs without a renumbering, and makes the two tiers distinguishable at a glance. -**Checked by:** `tests/registry.test.mjs` — *"core templates keep order 1..15 and a +**Checked by:** `tests/registry.test.mjs` — *"core templates keep order 1..17 and a custom template must use order >= 200"*, *"the selectable set is sorted by order…"*. ### 1.5 The frozen set The frozen set is **core-only**. `FROZEN_ARTIFACTS` in `tests/build.test.mjs` holds -eleven of the fifteen; `row`, `editorial`, `canvas` and `pulsenova` hold individual -lock tests. The union is exactly the fifteen core templates. +thirteen of the seventeen; `row`, `editorial`, `canvas` and `pulsenova` hold individual +lock tests. The union is exactly the seventeen core templates. > **A custom template MUST NOT appear in the frozen set.** @@ -101,7 +101,7 @@ and the build reads only `styles` and `emitDataTemplate` off a registry entry, s entry can add itself to the frozen set. The test that pins this fails if a `tier: 'custom'` id ever appears there. -**Checked by:** `tests/build.test.mjs` — *"the frozen set is exactly the fifteen core +**Checked by:** `tests/build.test.mjs` — *"the frozen set is exactly the seventeen core templates, and a custom template can never enter it"*. --- @@ -109,7 +109,7 @@ templates, and a custom template can never enter it"*. ## 2. Mandatory Runtime Contract Every custom template MUST satisfy the following. These are the same requirements -the fifteen core templates satisfy; there is no reduced contract for custom work. +the seventeen core templates satisfy; there is no reduced contract for custom work. | # | requirement | how it is checked | |---|---|---| @@ -142,7 +142,7 @@ The artifact carries three ` can never form in # the injected branding block). Control chars must be rejected by the caller. # LC_ALL=C keeps sed byte-oriented; UTF-8 trail bytes (>=0x80) never collide - # with the ASCII bytes \ " < being rewritten. - printf '%s' "$1" | LC_ALL=C sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ 0) ? substr(rest, 1, e - 1) : rest + } else { + c = index(v, " #"); if (c > 0) v = substr(v, 1, c - 1) + sub(/[ \t]+$/, "", v) + } + val = v; found = 1 + } + END { if (found) { printf "%s", val; exit 0 } exit 3 } + ' "$file" } # --- input validation -------------------------------------------------------- @@ -237,7 +318,7 @@ rt_config_write() { # The selectable ids of this release, in catalogue order. Row is first and is # the default. -RT_TEMPLATES_AVAILABLE="row editorial canvas prism terminal pulse brutal arcade sketch signature saffron pulsenova prismnova terminalnova arcadenova" +RT_TEMPLATES_AVAILABLE="row editorial canvas prism terminal pulse brutal arcade sketch signature saffron pulsenova prismnova terminalnova arcadenova meter notebook" rt_template_allowed() { local id @@ -264,6 +345,8 @@ rt_template_display_name() { prismnova) printf 'Prism Nova' ;; terminalnova) printf 'Terminal Nova' ;; arcadenova) printf 'Arcade Nova' ;; + meter) printf 'Meter' ;; + notebook) printf 'Notebook' ;; *) printf '%s' "$1" ;; esac } @@ -431,26 +514,180 @@ rt_stage_template_store() { # anything is staged. A payload without a templates/ directory (an older # release) simply carries no store; that is the caller's signal to fall back # to the top-level artifact. - local payload="$1" dir id want - [ -d "$payload/templates" ] || return 0 - for dir in "$payload"/templates/*/; do + # + # The designs come from the payload subtree of the panel this install serves + # (rt_payload_store): templates//template.html for 3X-UI, and + # shells///shell.html for PasarGuard and Rebecca. Either way they + # land in the store under the same name, so everything after staging is + # panel-agnostic. Every artifact must also fit the panel + # (rt_artifact_fits_panel) -- a store must never hold a page the panel + # cannot render safely. + local payload="$1" dir id want spec sub name sidecar + spec="$(rt_payload_store)"; sub="${spec%%|*}"; name="${spec#*|}" + [ -d "$payload/$sub" ] || return 0 + for dir in "$payload/$sub"/*/; do [ -d "$dir" ] || continue id="$(basename "$dir")" case "$id" in *[!a-z0-9]*|"") rt_warn "payload template directory is not a plain id: $id (skipped)"; continue ;; esac - [ -f "$dir/template.html" ] || { rt_warn "payload template $id has no template.html (skipped)"; continue; } - [ -f "$dir/template.html.sha256" ] || { rt_err "payload template $id has no checksum sidecar"; return 1; } - want="$(LC_ALL=C awk '{print $1; exit}' "$dir/template.html.sha256")" - rt_verify_sha256 "$dir/template.html" "$want" || { rt_err "payload template $id failed its checksum"; return 1; } - rt_validate_template "$dir/template.html" || { rt_err "payload template $id failed structural validation"; return 1; } + [ -f "$dir/$name" ] || { rt_warn "payload template $id has no $name (skipped)"; continue; } + sidecar="$dir/$name.sha256" + [ -f "$sidecar" ] || { rt_err "payload template $id has no checksum sidecar"; return 1; } + want="$(LC_ALL=C awk '{print $1; exit}' "$sidecar")" + rt_verify_sha256 "$dir/$name" "$want" || { rt_err "payload template $id failed its checksum"; return 1; } + rt_validate_template "$dir/$name" || { rt_err "payload template $id failed structural validation"; return 1; } + rt_artifact_fits_panel "$dir/$name" \ + || { rt_err "payload template $id is not a $(rt_panel_label "$(rt_panel_current)") page"; return 1; } + rt_assert_not_symlink "$RT_TEMPLATE_STORE/$id" || return 1 mkdir -p "$RT_TEMPLATE_STORE/$id" || return 1 - rt_atomic_install "$dir/template.html" "$RT_TEMPLATE_STORE/$id/template.html" 644 || return 1 - rt_atomic_install "$dir/template.html.sha256" "$RT_TEMPLATE_STORE/$id/template.html.sha256" 644 || return 1 + rt_atomic_install "$dir/$name" "$RT_TEMPLATE_STORE/$id/template.html" 644 || return 1 + rt_atomic_install "$sidecar" "$RT_TEMPLATE_STORE/$id/template.html.sha256" 644 || return 1 done return 0 } +# --- template store self-healing --------------------------------------------- +# Every reader above looks in ONE place, RT_TEMPLATE_STORE. A store anywhere +# else is invisible, and the manager then reports that no design is installed +# while the files sit one directory away. Two states lead there on real hosts: +# +# ABSENT v1.1.0's updater installs this library but copies only four +# files, so no design arrives with it (rt_complete_install). +# MISPLACED a release payload lays its designs out at templates//; a +# payload copied or extracted over the install root leaves them +# at $RT_ROOT/templates, beside dist/ instead of inside it. +# +# rt_repair_template_store heals from what the host already has; install, +# update and verify all run it, so a path mistake is repaired by whichever +# command meets it first and no operator has to move a file by hand. + +# Where a store has been found outside its home, relative to RT_ROOT. A closed +# list of fixed paths inside the install root, never derived from input. +RT_TEMPLATE_STORE_MISPLACED="templates" + +rt_template_entry_ok() { + # 0 when DIR holds a design whose artifact matches its own sidecar. No + # symlinks: a linked directory or file could hand over bytes from outside the + # install root. Silent; callers report. + local d="$1" want + [ -d "$d" ] && [ ! -L "$d" ] || return 1 + [ -f "$d/template.html" ] && [ ! -L "$d/template.html" ] || return 1 + [ -f "$d/template.html.sha256" ] && [ ! -L "$d/template.html.sha256" ] || return 1 + want="$(LC_ALL=C awk '{print $1; exit}' "$d/template.html.sha256" 2>/dev/null)" + rt_verify_sha256 "$d/template.html" "$want" >/dev/null 2>&1 +} + +rt_template_store_status() { + # echo ok | missing | corrupt for registry ID in the installed store. + # "missing" is a design with neither file; anything else short of a + # verifying pair (one file alone, a checksum mismatch, a symlink) is corrupt. + local id="$1" d + rt_template_allowed "$id" || return 1 + d="$RT_TEMPLATE_STORE/$id" + if rt_template_entry_ok "$d"; then printf 'ok'; return 0; fi + if [ ! -e "$d/template.html" ] && [ ! -e "$d/template.html.sha256" ] && [ ! -L "$d" ]; then + printf 'missing' + else + printf 'corrupt' + fi +} + +rt_template_store_missing() { + # echo the registry ids the store cannot supply (missing or corrupt), one per + # line, in catalogue order. Empty output means the store is complete. + local id + for id in $RT_TEMPLATES_AVAILABLE; do + [ "$(rt_template_store_status "$id")" = "ok" ] || printf '%s\n' "$id" + done + return 0 +} + +rt_template_store_retire() { + # remove a misplaced store's copy of every design the canonical store now + # supplies. Only the two files a design consists of are removed, only for + # registry ids, and only through real directories; a directory is then + # removed only if that left it empty. Anything else -- a foreign file, an + # unknown id, a copy of a design the store still lacks -- stays where it is. + local src="$1" id + for id in $RT_TEMPLATES_AVAILABLE; do + [ -d "$src/$id" ] && [ ! -L "$src/$id" ] || continue + [ "$(rt_template_store_status "$id")" = "ok" ] || continue + rm -f -- "$src/$id/template.html" "$src/$id/template.html.sha256" + rmdir -- "$src/$id" 2>/dev/null || true + done + rmdir -- "$src" 2>/dev/null || true +} + +rt_repair_template_store() { + # Make RT_TEMPLATE_STORE hold a verified copy of every design this release + # offers, from the sources on hand, in order of authority: + # + # 1. PAYLOAD's templates/, when given: a release that already passed its + # checksum. rt_stage_template_store verifies and installs every design. + # 2. a misplaced store inside the install root: a design the store cannot + # supply is taken from it only when that copy matches its own checksum + # and passes the structural gate; a copy that does not is reported and + # left in place. + # + # A misplaced copy is retired once the store covers its design, so the tree + # is left with one store, not two. Backups, config.env, the canonical + # artifact and the live page are never touched: this only fills the store. + # + # Returns 0 when the store is complete, 2 when designs are still missing (the + # caller decides whether that matters: an older payload carries no store at + # all), and 1 when the payload fails verification or a write fails. + local payload="${1:-}" rel src id moved warned + if [ -L "$RT_TEMPLATE_STORE" ] || [ -L "$(dirname "$RT_TEMPLATE_STORE")" ]; then + rt_err "the template store path is a symlink; refusing to repair it: $RT_TEMPLATE_STORE" + return 1 + fi + if [ -e "$RT_TEMPLATE_STORE" ] && [ ! -d "$RT_TEMPLATE_STORE" ]; then + rt_err "the template store path is not a directory: $RT_TEMPLATE_STORE" + return 1 + fi + + if [ -n "$payload" ]; then + rt_stage_template_store "$payload" || return 1 + fi + + for rel in $RT_TEMPLATE_STORE_MISPLACED; do + src="$RT_ROOT/$rel" + [ -e "$src" ] || [ -L "$src" ] || continue + if [ -L "$src" ] || [ ! -d "$src" ]; then + rt_warn "not reading templates from $src: it is not a plain directory." + continue + fi + moved=0; warned=0 + for id in $RT_TEMPLATES_AVAILABLE; do + [ -e "$src/$id" ] || [ -L "$src/$id" ] || continue + [ "$(rt_template_store_status "$id")" = "ok" ] && continue + if ! rt_template_entry_ok "$src/$id" \ + || ! rt_validate_template "$src/$id/template.html" >/dev/null 2>&1 \ + || ! rt_artifact_fits_panel "$src/$id/template.html"; then + rt_warn "the copy of design '$id' in $src fails its checksum or structural check; it was not moved." + warned=1 + continue + fi + rt_assert_not_symlink "$RT_TEMPLATE_STORE/$id" || return 1 + mkdir -p "$RT_TEMPLATE_STORE/$id" || return 1 + rt_atomic_install "$src/$id/template.html" "$RT_TEMPLATE_STORE/$id/template.html" 644 || return 1 + rt_atomic_install "$src/$id/template.html.sha256" "$RT_TEMPLATE_STORE/$id/template.html.sha256" 644 || return 1 + moved=$((moved + 1)) + done + rt_template_store_retire "$src" + if [ "$moved" -gt 0 ]; then + rt_ok "Template store: moved $moved design(s) from $src to $RT_TEMPLATE_STORE." + fi + if [ -e "$src" ] && [ "$warned" -eq 0 ]; then + rt_warn "left $src in place: it holds files Row-Template does not recognise." + fi + done + + [ -z "$(rt_template_store_missing)" ] && return 0 + return 2 +} + rt_switch_template() { # switch the active template as one transaction. Ordered so that nothing on # disk changes until the candidate has passed every check, and so that any @@ -624,9 +861,17 @@ rt_validate_template() { size="$(rt_file_size "$f")" || { rt_err "cannot size generated template"; return 1; } [ "$size" -ge $((40 * 1024)) ] \ || { rt_err "generated template implausibly small (${size} bytes)"; return 1; } - LC_ALL=C head -c 512 "$f" | LC_ALL=C grep -qi '' \ + # No `head | grep -q` here. Under pipefail, grep -q exits on its first match, + # head can die of SIGPIPE writing the rest, and pipefail reports the match as + # a failure: measured on a loaded Linux host (1.3.0) at ~0.7% of calls, which + # made install, update and design switching refuse a perfectly valid page. + # tr reads its whole input, so nothing in these pipelines can be cut short. + local lead trail + lead="$(LC_ALL=C head -c 512 "$f" 2>/dev/null | LC_ALL=C tr -d '\000' | LC_ALL=C tr '[:upper:]' '[:lower:]')" + [[ "$lead" == *''* ]] \ || { rt_err "generated template does not begin with "; return 1; } - LC_ALL=C tail -c 64 "$f" | LC_ALL=C grep -q '' \ + trail="$(LC_ALL=C tail -c 64 "$f" 2>/dev/null | LC_ALL=C tr -d '\000')" + [[ "$trail" == *''* ]] \ || { rt_err "generated template does not end with "; return 1; } nopen="$(LC_ALL=C grep -Fc '/* row:branding */' "$f" || true)" nclose="$(LC_ALL=C grep -Fc '/* row:branding end */' "$f" || true)" @@ -810,9 +1055,27 @@ rt_backup_create() { ver="$(cat "$RT_VERSION_FILE" 2>/dev/null || echo unknown)" ver="$(printf '%s' "$ver" | LC_ALL=C tr -cd 'A-Za-z0-9._-')" [ -n "$ver" ] || ver="unknown" + # Names have one-second resolution. Two backups in the same second -- a design + # switch followed at once by `rollback --auto`, which snapshots the current + # state first -- used to share one directory (`mkdir -p` reuses it), so the + # second silently overwrote the first: the very backup the rollback was about + # to restore. The name is now claimed with a plain mkdir, which fails when it + # is taken, and a taken name waits for the next second -- exactly as the + # format-2 writer below does -- so names stay unique and a lexical sort stays + # chronological. + mkdir -p "$RT_BACKUPS" || return 1 + local waited=0 ts="$(date -u +%Y%m%dT%H%M%SZ)" dir="$RT_BACKUPS/${ts}__${ver}" - mkdir -p "$dir" || return 1 + until mkdir "$dir" 2>/dev/null; do + if [ -e "$dir" ] && [ "$waited" -lt 3 ]; then + waited=$((waited + 1)); sleep 1 + ts="$(date -u +%Y%m%dT%H%M%SZ)"; dir="$RT_BACKUPS/${ts}__${ver}" + continue + fi + rt_err "could not create a new backup directory under $RT_BACKUPS" + return 1 + done cp -- "$RT_DIST" "$dir/template.html" || { rt_safe_rmdir "$dir"; return 1; } rt_sha256 "$dir/template.html" > "$dir/template.html.sha256" \ || { rt_safe_rmdir "$dir"; return 1; } @@ -825,6 +1088,9 @@ rt_backup_create() { local tpl_id tpl_id="$(rt_template_id_for_artifact "$RT_DIST")" [ -n "$tpl_id" ] && printf 'template=%s\n' "$tpl_id" >> "$dir/meta" + # the panel the artifact was made for (1.3.0+); a backup without it is 3X-UI. + # Unlike template=, this one IS read: a restore refuses another panel's backup. + printf 'panel=%s\n' "$(rt_panel_current)" >> "$dir/meta" chmod 700 "$dir" 2>/dev/null || true [ -f "$dir/config.env" ] && chmod 640 "$dir/config.env" 2>/dev/null || true printf '%s' "$dir" @@ -852,7 +1118,10 @@ rt_backups_list() { done | LC_ALL=C sort -r } -rt_backup_latest() { rt_backups_list | head -n1; } +rt_backup_latest() { + # sed reads its whole input, so the list cannot be cut short by SIGPIPE + rt_backups_list | LC_ALL=C sed -n 1p +} rt_backups_prune() { # keep the KEEP newest valid backups (KEEP>=2 enforced by callers); delete the @@ -1223,6 +1492,9 @@ rt_backup_snapshot_check() { rt_backup_panel_state "$dir" "$p" >/dev/null || return 1 rt_backup_panel_meta_check "$dir" "$p" || return 1 rt_backup_panel_files "$dir" "$p" >/dev/null || return 1 + if [ -e "$dir/panels/$p/aux" ] || [ -L "$dir/panels/$p/aux" ]; then + rt_backup_panel_aux_check "$dir/panels/$p/aux" || return 1 + fi done return 0 } @@ -1366,6 +1638,72 @@ rt_backup_panel_write() { return 0 } +# --- the aux record (1.3.0) --------------------------------------------------- +# A panel may need more than one setting recorded to be restored exactly -- +# Rebecca selects its page by TWO columns, and one of them distinguishes NULL +# from ''. `selection` stays the panel's primary selection, unchanged; `aux` +# is an OPTIONAL file beside it holding further values: +# +# = one per line, LC_ALL=C sorted +# +# Keys are a closed grammar ([a-z_], 1-32 chars) and values are base64, so no +# value can break a line or be read as anything but data. A missing key reads +# as the empty string. A snapshot without `aux` is exactly a pre-1.3.0 one, and +# every reader accepts it; a present `aux` must be well formed or the snapshot +# is refused (rt_backup_snapshot_check). + +rt_backup_panel_aux_key_ok() { + case "${1:-}" in ''|*[!a-z_]*) return 1 ;; esac + [ "${#1}" -le 32 ] +} + +rt_backup_panel_aux_check() { + # 0 when FILE is a well-formed aux record: key=base64 lines, unique keys. + local f="$1" line key seen="" + [ -L "$f" ] && return 1 + [ -f "$f" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + key="${line%%=*}" + [ "$key" != "$line" ] || return 1 + rt_backup_panel_aux_key_ok "$key" || return 1 + case "${line#*=}" in *[!A-Za-z0-9+/=]*) return 1 ;; esac + case ",$seen," in *",$key,"*) return 1 ;; esac + seen="${seen:+$seen,}$key" + done < "$f" + return 0 +} + +rt_backup_panel_aux_set() { + # rt_backup_panel_aux_set STAGE PANEL KEY VALUE -- record VALUE under KEY for + # a panel already staged by rt_backup_panel_write. Replaces an earlier value. + local stage="${1:-}" panel="${2:-}" key="${3:-}" value="${4:-}" d f tmp + rt_panel_id_ok "$panel" || { rt_err "aux: unknown panel id: $panel"; return 1; } + rt_backup_panel_aux_key_ok "$key" || { rt_err "aux: not a legal key: $key"; return 1; } + d="$stage/$panel" + [ -d "$d" ] && [ ! -L "$d" ] || { rt_err "aux: panel $panel is not staged"; return 1; } + f="$d/aux" + tmp="$(mktemp "$d/.aux.XXXXXX")" || return 1 + { + if [ -f "$f" ]; then LC_ALL=C grep -v "^${key}=" "$f" || true; fi + printf '%s=%s\n' "$key" "$(printf '%s' "$value" | rt_b64_encode)" + } | LC_ALL=C sort > "$tmp" || { rm -f "$tmp"; return 1; } + mv -f "$tmp" "$f" || { rm -f "$tmp"; return 1; } +} + +rt_backup_panel_aux() { + # rt_backup_panel_aux SNAPSHOT PANEL KEY -- echo the recorded value (empty + # when the key or the whole record is absent). 1 when the record is malformed. + local snap="${1:-}" panel="${2:-}" key="${3:-}" f line + rt_panel_id_ok "$panel" || return 1 + rt_backup_panel_aux_key_ok "$key" || return 1 + f="$snap/panels/$panel/aux" + [ -e "$f" ] || [ -L "$f" ] || return 0 + rt_backup_panel_aux_check "$f" || return 1 + line="$(LC_ALL=C grep "^${key}=" "$f" || true)" + [ -n "$line" ] || return 0 + printf '%s' "${line#*=}" | rt_b64_decode +} + rt_backup_manifest_write() { # Write SNAPDIR/manifest: one " " line per captured # file, LC_ALL=C sorted, never including the manifest itself. @@ -1453,6 +1791,7 @@ rt_backup_create_v2() { || { rt_safe_rmdir "$tmp"; return 1; } tpl_id="$(rt_template_id_for_artifact "$RT_DIST")" [ -n "$tpl_id" ] && printf 'template=%s\n' "$tpl_id" >> "$tmp/meta" + printf 'panel=%s\n' "$(rt_panel_current)" >> "$tmp/meta" # NOTE: no `format=` key is written into meta, and no `panels=` key either. # The format lives in its own canonical file; the panel list is the # filesystem, because a key can claim a panel whose state is not on disk. @@ -1465,7 +1804,7 @@ rt_backup_create_v2() { [ -d "$src" ] || { rt_err "no staged state for panel: $panel"; rt_safe_rmdir "$tmp"; return 1; } d="$tmp/panels/$panel" mkdir -p "$d" || { rt_safe_rmdir "$tmp"; return 1; } - for f in selection.state selection meta files; do + for f in selection.state selection meta files aux; do [ -e "$src/$f" ] || [ -L "$src/$f" ] || { # `files` is REQUIRED for a touched panel; the other three are # conditional on the state word. A staged panel without a files list @@ -1684,7 +2023,7 @@ rt_smoke_derive_url() { [ -n "$port" ] || port=2096 [ -n "$path" ] || path="/sub/" sid="$(sqlite3 "$RT_XUI_DB" "SELECT settings FROM inbounds LIMIT 200;" 2>/dev/null \ - | LC_ALL=C grep -oE '"subId"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n1 \ + | LC_ALL=C grep -oE '"subId"[[:space:]]*:[[:space:]]*"[^"]+"' | LC_ALL=C sed -n 1p \ | LC_ALL=C sed -E 's/.*"([^"]+)"$/\1/' || true)" [ -n "$sid" ] || return 1 case "$path" in /*) : ;; *) path="/$path" ;; esac @@ -1692,6 +2031,18 @@ rt_smoke_derive_url() { printf 'http://127.0.0.1:%s%s%s' "$port" "$path" "$sid" } +rt_xui_just_started() { + # 0 when the 3X-UI unit entered "active" less than 30 s ago (monotonic clock, + # so a wall-clock change cannot fake it). Anything unknown answers no. + command -v systemctl >/dev/null 2>&1 || return 1 + local since up + since="$(systemctl show -p ActiveEnterTimestampMonotonic --value "${RT_XUI_UNIT:-x-ui.service}" 2>/dev/null || true)" + case "$since" in ''|0|*[!0-9]*) return 1 ;; esac + up="$(LC_ALL=C awk '{ printf "%d", $1 * 1000000 }' /proc/uptime 2>/dev/null || true)" + case "$up" in ''|*[!0-9]*) return 1 ;; esac + [ "$up" -ge "$since" ] && [ $((up - since)) -lt 30000000 ] +} + rt_render_smoke() { # classify what a browser request receives: pass (Row-Template served), # fallback (built-in default served — our template not active), skip (no test @@ -1702,11 +2053,21 @@ rt_render_smoke() { [ -n "$url" ] || url="$(rt_smoke_derive_url || true)" [ -n "$url" ] || { printf 'skip'; return 0; } command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; } - body="$(curl -fsS -m 10 -A 'Mozilla/5.0' -H 'Accept: text/html' "$url" 2>/dev/null || true)" - if [ -z "$body" ]; then - url="https://${url#http://}" - body="$(curl -fsS -m 10 -k -A 'Mozilla/5.0' -H 'Accept: text/html' "$url" 2>/dev/null || true)" - fi + local tries=1 + # Activation restarts 3X-UI, and its subscription server binds a few seconds + # after the unit reports active: a check made in that window reported "could + # not reach" on every fresh install. Wait for it only when the panel really + # did just start, so an endpoint that is simply unreachable still reports at + # once. + rt_xui_just_started && tries=8 + while :; do + body="$(curl -fsS -m 10 -A 'Mozilla/5.0' -H 'Accept: text/html' "$url" 2>/dev/null || true)" + if [ -z "$body" ]; then + body="$(curl -fsS -m 10 -k -A 'Mozilla/5.0' -H 'Accept: text/html' "https://${url#http://}" 2>/dev/null || true)" + fi + [ -z "$body" ] && [ "$tries" -gt 1 ] || break + tries=$((tries - 1)); sleep 2 + done [ -n "$body" ] || { printf 'error'; return 0; } # Pure-bash substring test on purpose. `printf %s "$big" | grep -q PAT` under # `set -o pipefail` misreports a match as failure: grep -q exits on the first @@ -1755,7 +2116,7 @@ rt_payload_companions() { local lib="$1/lib/row-template.sh" list rel local -a rels=() [ -f "$lib" ] || return 0 - list="$(LC_ALL=C sed -n 's/^RT_INSTALLER_COMPANIONS="\(.*\)"$/\1/p' "$lib" | head -n 1)" + list="$(LC_ALL=C sed -n 's/^RT_INSTALLER_COMPANIONS="\(.*\)"$/\1/p' "$lib" | LC_ALL=C sed -n 1p)" # split with read, never an unquoted expansion: the declaration is payload # data, and a word like panels/*.sh must reach the check below as written # rather than be glob-expanded first. @@ -1819,6 +2180,8 @@ rt_set_dist() { # SRC must already be a structurally valid Row-Template artifact. local src="$1" rt_validate_template "$src" || { rt_err "refusing to install an invalid artifact"; return 1; } + rt_artifact_fits_panel "$src" \ + || { rt_err "refusing to install an artifact that is not a $(rt_panel_label "$(rt_panel_current)") page"; return 1; } rt_atomic_install "$src" "$RT_DIST" 644 || return 1 rt_sha256 "$RT_DIST" > "$RT_DIST_SUM" || return 1 chmod 644 "$RT_DIST_SUM" 2>/dev/null || true @@ -1828,12 +2191,46 @@ rt_activate() { # regenerate sub.html from the current artifact + config, validate it, then # swap it into the live path atomically. The live file is never truncated: on # any failure the previous sub.html stays exactly as it was. - local staged dir + # + # On PasarGuard and Rebecca the panel reads a COPY of the page, placed in its + # own template directory; when one is placed it is replaced here too, so the + # panel never serves a page older than the one just generated. The panel's + # selection is not touched: that is activation's job (rt_panel_activate). + # + # The panel copy is refreshed AFTER sub.html is swapped, so a refresh that + # fails puts the previous sub.html back before returning: the contract above + # holds for every panel, and a caller that reports "nothing was changed" is + # telling the truth. + local staged dir panel prev="" rc=0 dir="$(dirname "$RT_LIVE")" + panel="$(rt_panel_current)" + if [ "$panel" != "3xui" ]; then + rt_installer_complete || { rt_err "the installer's panel components are missing; run 'row-template update'."; return 1; } + fi staged="$(mktemp "$dir/.live.XXXXXX")" || return 1 if ! rt_generate "$RT_DIST" "$staged"; then rm -f "$staged"; return 1; fi - rt_atomic_install "$staged" "$RT_LIVE" 644 || { rm -f "$staged"; return 1; } + if [ "$panel" != "3xui" ] && [ -f "$RT_LIVE" ]; then + prev="$(mktemp "$dir/.prev.XXXXXX")" || { rm -f "$staged"; return 1; } + cp -- "$RT_LIVE" "$prev" || { rm -f "$staged" "$prev"; return 1; } + fi + rt_atomic_install "$staged" "$RT_LIVE" 644 || { rm -f "$staged" "$prev"; return 1; } rm -f "$staged" + if [ "$panel" != "3xui" ]; then + rt_panel_refresh_page "$panel" "$RT_LIVE" || rc=$? + case "$rc" in + 0|3) : ;; + *) + if [ -n "$prev" ]; then + rt_atomic_install "$prev" "$RT_LIVE" 644 \ + || rt_err "could not put the previous page back at $RT_LIVE." + fi + rm -f "$prev" + rt_err "could not update the page in $(rt_panel_label "$panel")'s template directory." + return 1 ;; + esac + rm -f "$prev" + fi + return 0 } rt_monogram_preview() { @@ -1958,12 +2355,28 @@ rt_cleanup() { rt_render_report() { # informational: print what the panel actually serves. Never fails the caller; # strict PASS/FAIL semantics live in rt_cmd_verify. - local r rv + local r rv panel rc=0 + panel="$(rt_panel_current)" + if [ "$panel" != "3xui" ] && [ -n "${RT_PANELS_LOADED:-}" ] && [ -z "${RT_SMOKE_URL:-}" ]; then + # Without a subscription URL (a secret this tool never looks up), the + # strongest evidence is the running panel's own view of its settings. + rt_panel_verify "$panel" live >/dev/null 2>&1 || rc=$? + case "$rc" in + 0) rt_ok "Live check: the running $(rt_panel_label "$panel") uses the Row-Template page." ;; + 2) rt_info "Live check skipped (set RT_SMOKE_URL to a subscription URL to check the served page)." ;; + *) rt_warn "Live check: the running $(rt_panel_label "$panel") does not use the Row-Template page yet; run 'row-template verify'." ;; + esac + return 0 + fi + # The test URL is derived from the panel database. config, update and + # rollback reach this report without having located it, so the check used to + # skip on every 3X-UI host after those commands; locate it here, read-only. + [ -n "${RT_XUI_DB:-}" ] || rt_detect_xui_db >/dev/null 2>&1 || true r="$(rt_render_smoke)" case "$r" in pass) rt_ok "Live check: a browser request renders Row-Template." ;; fallback) rt_warn "Live check: the panel served its built-in page. If you just set the theme dir, restart the panel; otherwise run 'row-template verify'." ;; - skip) rt_info "Live check skipped (no test URL available without sqlite3)." ;; + skip) rt_info "Live check skipped (no test URL: it needs sqlite3, the panel database and a client with a subscription ID)." ;; error) rt_warn "Live check could not reach the subscription endpoint." ;; esac rv="$(rt_render_smoke_vpn)" @@ -1976,10 +2389,21 @@ rt_render_report() { } rt_print_activation_note() { - # $1 = auto | manual + # $1 = auto | manual | skipped | failed + local panel; panel="$(rt_panel_current)" rt_section "Row-Template installed successfully." + rt_info "Panel: $(rt_panel_label "$panel")" rt_info "Template directory: $RT_ROOT" - rt_info "Served file: $RT_LIVE" + rt_info "Generated page: $RT_LIVE" + if [ "$panel" != "3xui" ]; then + case "$1" in + auto) rt_ok "$(rt_panel_label "$panel") now serves the Row-Template page." ;; + manual) rt_section "One manual step remains"; rt_panel_manual_steps ;; + failed) rt_warn "Activation did not complete and was rolled back; run 'row-template' and choose Activate to retry." ;; + *) rt_info "Not activated yet: run 'row-template' and choose Activate when you are ready." ;; + esac + return 0 + fi if [ "$1" = "auto" ]; then rt_ok "Panel configured automatically: subThemeDir = $RT_ROOT" else @@ -1991,13 +2415,17 @@ rt_print_activation_note() { } rt_cmd_version() { - local rtv xuiv + local rtv xuiv panel rtv="$(cat "$RT_VERSION_FILE" 2>/dev/null || true)"; [ -n "$rtv" ] || rtv="unknown" - rt_detect_xui >/dev/null 2>&1 || true - xuiv="$(rt_detect_xui_version 2>/dev/null || true)"; [ -n "$xuiv" ] || xuiv="unknown" + panel="$(rt_installed_panel)" printf 'Row-Template %s\n' "$rtv" - printf 'Supported 3x-ui minimum: %s\n' "$RT_MIN_XUI" - printf 'Detected 3x-ui: %s\n' "$xuiv" + [ -n "$panel" ] && printf 'Panel: %s\n' "$(rt_panel_label "$panel")" + if [ -z "$panel" ] || [ "$panel" = "3xui" ]; then + rt_detect_xui >/dev/null 2>&1 || true + xuiv="$(rt_detect_xui_version 2>/dev/null || true)"; [ -n "$xuiv" ] || xuiv="unknown" + printf 'Supported 3x-ui minimum: %s\n' "$RT_MIN_XUI" + printf 'Detected 3x-ui: %s\n' "$xuiv" + fi } # --- release acquisition (download -> verify -> extract) --------------------- @@ -2127,6 +2555,91 @@ rt_remote_version() { printf '%s' "$v" } +rt_release_url_for_version() { + # echo the default channel's address for exactly VERSION: GitHub serves a + # tag's assets at releases/download/v, where releases/latest/download + # would serve whatever is newest. A default channel that is not GitHub's + # latest-release address is returned unchanged. + local ver="$1" base="${RT_DEFAULT_RELEASE_URL%/}" + case "$base" in + */releases/latest/download) printf '%s/releases/download/v%s' "${base%/releases/latest/download}" "$ver" ;; + *) printf '%s' "$base" ;; + esac +} + +rt_complete_install() { + # Complete an install that is short of what its own version ships: designs + # missing from the template store, or the library's companions. v1.1.0's + # updater leaves exactly that (it copies four files), and it is what made + # 1.2.0 need a second `row-template update`. The manager and `config` call + # this on start, so the first run of the new code finishes the job instead. + # + # 1. from the host: rt_repair_template_store (a misplaced store). No network. + # 2. only if something is still missing: a verified download of the + # INSTALLED version -- the same release, never a newer one. An explicit + # RT_RELEASE_DIR / RT_RELEASE_URL is used as given; the default channel + # is pinned to the installed tag. A payload of any other version is + # refused: installing it would be an update, which is `update`'s job. + # + # Only the store and the companions are written. The canonical artifact, the + # live page, config.env, the selection and every backup are left untouched. + # Quiet when there is nothing to do. 0 = complete, 1 = still incomplete + # (reported), which callers treat as a warning, never a reason to stop. + local rc=0 ver work payload pver n + [ -f "$RT_DIST" ] && [ -w "$RT_ROOT" ] || return 0 + rt_repair_template_store || rc=$? + [ "$rc" -eq 1 ] && return 1 + if [ "$rc" -eq 0 ] && rt_installer_complete; then return 0; fi + + ver="$(rt_trim "$(cat "$RT_VERSION_FILE" 2>/dev/null || true)")" + case "$ver" in + [0-9]*) case "$ver" in *[!A-Za-z0-9.+-]*) ver="" ;; esac ;; + *) ver="" ;; + esac + if [ -z "$ver" ]; then + rt_warn "this installation is incomplete and its version is unknown; run 'row-template update' to repair it." + return 1 + fi + rt_info "Completing the Row-Template $ver installation (designs and installer files)..." + work="$(rt_mktemp_dir)" || return 1 + RT_TMP_TO_CLEAN+=("$work") # register in THIS shell (see rt_mktemp_dir) + if [ -n "${RT_RELEASE_DIR:-}" ] || [ -n "${RT_RELEASE_URL:-}" ]; then + payload="$(rt_fetch_release "$work")" || payload="" + else + payload="$(RT_RELEASE_URL="$(rt_release_url_for_version "$ver")" rt_fetch_release "$work")" || payload="" + fi + if [ -z "$payload" ]; then + rm -rf -- "$work" + rt_warn "could not download Row-Template $ver to complete the installation. It will be retried the next time the manager opens; 'row-template update' also completes it." + return 1 + fi + pver="$(rt_trim "$(cat "$payload/VERSION" 2>/dev/null || true)")" + if [ "$pver" != "$ver" ]; then + rm -rf -- "$work" + rt_warn "the release source offers ${pver:-an unknown version}, not the installed $ver; nothing was changed. Run 'row-template update' to update." + return 1 + fi + if ! rt_payload_companions_ok "$payload"; then + rm -rf -- "$work" + return 1 + fi + rc=0; rt_repair_template_store "$payload" || rc=$? + rt_install_companions "$payload" \ + || rt_warn "could not install the management library's companions; run 'row-template update' to retry." + rm -rf -- "$work" + # load what was just installed, so this run already sees a complete install + rt_panels_load >/dev/null 2>&1 || true + rt_transaction_load >/dev/null 2>&1 || true + + if [ "$rc" -eq 0 ] && rt_installer_complete; then + n="$(rt_template_offered | grep -c . || true)" + rt_ok "Installation completed: $n design(s) available." + return 0 + fi + rt_warn "the installation is still incomplete; run 'row-template verify' for details." + return 1 +} + rt_restore_from_backup() { # install the artifact recorded in backup DIR as the canonical artifact and # restore its VERSION. Admin branding in config.env is deliberately left @@ -2134,15 +2647,42 @@ rt_restore_from_backup() { # the TEMPLATE selection, however, is re-derived from the artifact itself # (checksum match against the template store) and persisted, so the restored # artifact and the stored selection always agree — including when the backup - # predates the current release's store. - local dir="$1" tpl_id + # predates the current release's store. If the checksum match fails (the backup + # artifact is not byte-identical to any installed template), fall back to the + # template recorded in the backup's meta, then to Row as a last resort. + local dir="$1" tpl_id bpanel rt_backup_validate "$dir" || { rt_err "backup failed validation: $dir"; return 1; } - tpl_id="$(rt_template_id_for_artifact "$dir/template.html")" - if [ -z "$tpl_id" ]; then - rt_err "backup artifact matches no installed template; the template store may be damaged" + # A backup is only ever restored onto the panel it was made for. Every backup + # before 1.3.0 is a 3X-UI one, which is what an absent panel= means. + bpanel="$(rt_backup_meta panel "$dir")"; [ -n "$bpanel" ] || bpanel="3xui" + if [ "$bpanel" != "$(rt_panel_current)" ]; then + rt_err "backup $(basename "$dir") was made for $(rt_panel_label "$bpanel"), not $(rt_panel_label "$(rt_panel_current)"); refusing to restore it." return 1 fi - rt_set_dist "$dir/template.html" || return 1 + local src="$dir/template.html" + tpl_id="$(rt_template_id_for_artifact "$src")" + if [ -z "$tpl_id" ]; then + tpl_id="$(rt_backup_meta template "$dir")" + if [ -z "$tpl_id" ]; then + tpl_id="row" + rt_warn "backup artifact has no store match and no recorded template; defaulting to Row." + elif ! rt_template_allowed "$tpl_id"; then + rt_warn "backup artifact recorded template '$tpl_id' is not installed; defaulting to Row." + tpl_id="row" + fi + # The backup's own page is not one of this release's designs (a 1.1.0 + # backup restored under 1.3.0). Selecting Row while keeping those bytes left + # the install failing verify -- "canonical artifact does not match the + # selected template" -- and unable to be switched or updated cleanly, found + # rolling a real 3X-UI host back to its 1.1.0 backup. The selected design is + # restored from the installed store instead; the backup's VERSION and the + # admin's current branding are handled exactly as before. + if rt_template_store_has "$tpl_id"; then + src="$RT_TEMPLATE_STORE/$tpl_id/template.html" + rt_info "The backup's page is not one of this release's designs; restoring $(rt_template_display_name "$tpl_id") from the installed designs." + fi + fi + rt_set_dist "$src" || return 1 if [ -f "$dir/VERSION" ]; then rt_atomic_install "$dir/VERSION" "$RT_VERSION_FILE" 644 \ || rt_warn "could not restore VERSION from the backup." @@ -2167,6 +2707,302 @@ rt_subtheme_clear_sqlite() { [ -z "$cur" ] } +# --- panels: which panel an install serves (1.3.0) --------------------------- +# Row-Template installs onto exactly one panel per host. The panel decides +# three things and nothing else: +# +# the ARTIFACT a Go template for 3X-UI, a Jinja2 page for PasarGuard, a +# pongo2 page for Rebecca (the release ships all three) +# the ROOT see RT_ROOT above +# ACTIVATION 3X-UI: subThemeDir (unchanged since 1.0.0). PasarGuard and +# Rebecca: their adapter, driven by the transaction engine. +# +# Branding, the template store, backups, rollback and the manager are the same +# code for all three. The panel an install serves is recorded in RT_PANEL_FILE; +# an install without the record predates 1.3.0, and every such install is 3X-UI. + +RT_ACTIVE_PANEL="" + +rt_panel_label() { + case "${1:-}" in + 3xui) printf '3X-UI' ;; + pasarguard) printf 'PasarGuard' ;; + rebecca) printf 'Rebecca' ;; + *) printf '%s' "${1:-unknown}" ;; + esac +} + +rt_installed_panel() { + # echo the panel of the install at RT_ROOT, or nothing when none is there. + local p + if [ -f "$RT_PANEL_FILE" ] && [ ! -L "$RT_PANEL_FILE" ]; then + p="$(head -n1 "$RT_PANEL_FILE" 2>/dev/null | LC_ALL=C tr -cd 'a-z0-9')" + if rt_panel_id_ok "$p"; then printf '%s' "$p"; return 0; fi + rt_warn "the panel record $RT_PANEL_FILE is not a panel this release knows; treating the install as 3X-UI." + fi + if [ -f "$RT_VERSION_FILE" ] || [ -f "$RT_DIST" ]; then printf '3xui'; fi + return 0 +} + +rt_panel_current() { + # echo the panel the current command acts on: the one being installed, else + # the installed one, else 3X-UI (the only panel before 1.3.0). + local p="${RT_ACTIVE_PANEL:-}" + [ -n "$p" ] || p="$(rt_installed_panel)" + [ -n "$p" ] || p="3xui" + printf '%s' "$p" +} + +rt_panel_record() { + # persist PANEL as the panel this install serves, atomically. + local tmp + rt_panel_id_ok "$1" || return 1 + rt_assert_not_symlink "$RT_PANEL_FILE" || return 1 + tmp="$(mktemp "$RT_ROOT/.panel.XXXXXX")" || return 1 + printf '%s\n' "$1" > "$tmp" && chmod 644 "$tmp" && mv -f "$tmp" "$RT_PANEL_FILE" || { rm -f "$tmp"; return 1; } +} + +rt_panel_on_host() { + # 0 when PANEL is on this host. 3X-UI keeps the rule it has had since 1.0.0 + # (its binary or its unit); PasarGuard and Rebecca need their adapter's two + # corroborating signals. Read-only. + local rc=0 + case "$1" in + 3xui) rt_detect_xui >/dev/null 2>&1 ;; + pasarguard|rebecca) + [ -n "${RT_PANELS_LOADED:-}" ] || return 1 + rt_panel_detect "$1" >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 0 ] ;; + *) return 1 ;; + esac +} + +rt_panels_on_host() { + # echo every panel on this host, one per line, in RT_PANEL_IDS order. + local p + for p in $RT_PANEL_IDS; do + if rt_panel_on_host "$p"; then printf '%s\n' "$p"; fi + done + return 0 +} + +rt_existing_root() { + # echo the root of an existing install on this host (RT_ROOT when it was set + # explicitly), or nothing. Two installs at once are refused: which one the + # CLI manages would be a guess. + local found="" r + if [ -n "$RT_ROOT_EXPLICIT" ]; then + [ -f "$RT_ROOT/VERSION" ] && printf '%s' "$RT_ROOT" + return 0 + fi + for r in "$RT_ROOT_3XUI" "$RT_ROOT_SHARED"; do + [ -f "$r/VERSION" ] || continue + if [ -n "$found" ]; then + rt_err "Row-Template is installed twice ($found and $r); remove one with 'row-template uninstall' before continuing." + return 1 + fi + found="$r" + done + printf '%s' "$found" +} + +rt_panel_choose() { + # Decide the panel to install for: set RT_ACTIVE_PANEL and move RT_ROOT to + # that panel's root, IN THIS SHELL (never call it inside $( ): the root + # change would be lost with the subshell). In order: + # 1. an existing install: its panel, at its root (a repair or re-run); + # 2. RT_PANEL from the environment, which must name a panel on this host; + # 3. the one panel on this host; with several, the operator chooses + # (interactively) or must set RT_PANEL. + # Non-zero, with the reason printed, when no panel can be chosen. + local root panel found p n=0 i=0 choice + local -a list=() + root="$(rt_existing_root)" || return 1 + if [ -n "$root" ]; then + [ -n "$RT_ROOT_EXPLICIT" ] || rt_root_set "$root" + panel="$(rt_installed_panel)" + if [ -n "${RT_PANEL:-}" ] && [ "${RT_PANEL}" != "$panel" ]; then + rt_err "Row-Template is installed for $(rt_panel_label "$panel") at $RT_ROOT; uninstall it before installing for $(rt_panel_label "$RT_PANEL")." + return 1 + fi + RT_ACTIVE_PANEL="$panel" + return 0 + fi + + if [ -n "${RT_PANEL:-}" ]; then + rt_panel_id_ok "$RT_PANEL" || { rt_err "RT_PANEL='$RT_PANEL' is not a panel Row-Template supports (3xui, pasarguard, rebecca)."; return 1; } + if ! rt_panel_on_host "$RT_PANEL"; then + if [ "$RT_PANEL" = "3xui" ]; then rt_err "no 3x-ui installation was detected on this host." + else rt_err "RT_PANEL=$RT_PANEL, but $(rt_panel_label "$RT_PANEL") was not detected on this host."; fi + return 1 + fi + panel="$RT_PANEL" + else + found="$(rt_panels_on_host)" + n="$(printf '%s' "$found" | grep -c . || true)" + if [ "$n" -eq 0 ]; then + rt_panel_report_partial + rt_err "no supported panel was detected on this host: no 3x-ui installation, and no PasarGuard or Rebecca installation." + return 1 + elif [ "$n" -eq 1 ]; then + panel="$found" + elif rt_ui_is_interactive; then + { rt_ui_section "More than one panel is installed on this host"; } >&2 + while IFS= read -r p; do list+=("$p"); done <<< "$found" + for p in "${list[@]}"; do + i=$((i + 1)); printf ' %s%d%s %s\n' "$RT_C_BLD" "$i" "$RT_C_RST" "$(rt_panel_label "$p")" >&2 + done + choice="$(rt_ui_menu_select "$n")" + [ "$choice" -ge 1 ] 2>/dev/null || { rt_err "no panel was chosen; nothing was changed."; return 1; } + panel="${list[$((choice - 1))]}" + else + rt_err "more than one panel is installed here ($(printf '%s' "$found" | tr '\n' ' ')); choose one with RT_PANEL=3xui|pasarguard|rebecca." + return 1 + fi + fi + if [ -z "$RT_ROOT_EXPLICIT" ] && [ "$panel" != "3xui" ]; then rt_root_set "$RT_ROOT_SHARED"; fi + RT_ACTIVE_PANEL="$panel" + return 0 +} + +rt_panel_report_partial() { + # Say so when a panel is half-there (one detection signal): refusing to act + # on it is right, but the operator deserves to know why. + local p rc + [ -n "${RT_PANELS_LOADED:-}" ] || return 0 + for p in pasarguard rebecca; do + rc=0; rt_panel_detect "$p" >/dev/null 2>&1 || rc=$? + if [ "$rc" -eq "$RT_PANEL_FAIL" ]; then + rt_warn "$(rt_panel_label "$p") looks partly installed (only one of its files was found); it is not treated as present." + fi + done + return 0 +} + +rt_payload_store() { + # echo "|": where the release payload keeps this panel's designs. + case "$(rt_panel_current)" in + 3xui) printf 'templates|template.html' ;; + *) printf 'shells/%s|shell.html' "$(rt_panel_current)" ;; + esac +} + +rt_artifact_fits_panel() { + # 0 when FILE is an artifact for the panel this install serves. The store, + # a backup or a payload can each hand the wrong one over, and each would + # break the page in its own way: a Go template on PasarGuard renders its + # actions as text; a Jinja2 page on 3X-UI fails to parse. A shell from + # before 1.3.0 is refused on PasarGuard and Rebecca: it has no context + # prelude (the page would render empty) and no autoescape block. + local f="$1" + case "$(rt_panel_current)" in + 3xui) + if LC_ALL=C grep -Eq '\{%-? *(autoescape|if|for|set|comment) ' "$f" 2>/dev/null; then return 1; fi + return 0 ;; + pasarguard) declare -F rt_panel_pasarguard_shell_ok >/dev/null && rt_panel_pasarguard_shell_ok "$f" ;; + rebecca) declare -F rt_panel_rebecca_shell_ok >/dev/null && rt_panel_rebecca_shell_ok "$f" ;; + *) return 1 ;; + esac +} + +rt_panel_activation_record() { + # remember SNAPSHOT as the state before Row-Template took over the panel, for + # uninstall to go back to -- unless the panel was ALREADY showing + # Row-Template when it was taken (a re-apply), in which case the earlier + # record is the true "before" and is kept. + local snap="$1" panel="$2" st v + [ -n "$snap" ] || return 0 + st="$(rt_backup_panel_state "$snap" "$panel" 2>/dev/null || true)" + v="$(rt_backup_panel_selection "$snap" "$panel" 2>/dev/null || true)" + if [ "$st" = "present" ] && [ "$v" = "row-template/index.html" ] && [ -f "$RT_PANEL_ACTIVATION" ]; then + return 0 + fi + printf '%s\n' "$(basename "$snap")" > "$RT_PANEL_ACTIVATION" 2>/dev/null || true + chmod 600 "$RT_PANEL_ACTIVATION" 2>/dev/null || true +} + +rt_panel_activate() { + # Make PasarGuard or Rebecca serve the generated page. Echo the outcome: + # auto placed and selected, through the transaction engine (snapshot, + # verify, and an automatic restore if anything fails) + # manual the page is placed, but the selection cannot be written here + # (Rebecca on MySQL/MariaDB, or without sqlite3): the operator + # selects it in the panel (rt_print_panel_manual) + # Non-zero when activation failed; the panel is then as it was. + local panel rc=0 + panel="$(rt_panel_current)" + rt_installer_complete || { rt_err "the installer's panel components are missing; run 'row-template update'."; return 1; } + rt_panel_preflight "$panel" || return 1 + if [ "$(rt_panel_status "$panel")" = "manual" ]; then + rt_panel_refresh_page "$panel" "$RT_LIVE" place >/dev/null || rc=$? + [ "$rc" -eq 0 ] || { rt_err "could not place the page for $(rt_panel_label "$panel")."; return 1; } + printf 'manual' + return 0 + fi + local errf + errf="$(mktemp)" || return 1 + if RT_TXN_QUIET=1 rt_transaction_run "$panel" "$RT_LIVE" >&2 2>"$errf"; then + cat "$errf" >&2; rm -f "$errf" + rt_panel_activation_record "$RT_TXN_SNAPSHOT" "$panel" + printf 'auto' + return 0 + fi + # The engine claims ROLLED_BACK only when its post-restore static check + # passes, and that check asks the INSTALL question ("does the panel serve + # Row-Template?"), whose honest answer after a rollback is no -- so a clean + # restore is reported as a failed one. Whether the restore was exact is + # decided here instead, by capturing the panel's state again and comparing it + # with the snapshot the transaction took before it changed anything. + if [ "${RT_TXN_MUTATED:-0}" = "1" ] && rt_panel_restore_confirmed "$panel" "${RT_TXN_SNAPSHOT:-}"; then + LC_ALL=C awk '{ print } /transaction: (template placement failed|static verification did not pass|live verification failed)/ { exit }' "$errf" >&2 + rt_warn "$(rt_panel_label "$panel") was restored exactly to its state before the attempt." + else + cat "$errf" >&2 + fi + rm -f "$errf" + return 1 +} + +rt_panel_restore_confirmed() { + # 0 when PANEL's state now equals what SNAPSHOT recorded before the change: + # the same record, captured again by the adapter, byte for byte. Anything that + # cannot be captured or compared is "not confirmed". + local panel="$1" snap="$2" f a b + [ -n "$snap" ] && [ -d "$snap/panels/$panel" ] || return 1 + rt_transaction_stage_reset >/dev/null 2>&1 || return 1 + rt_panel_backup_state "$panel" >/dev/null 2>&1 || return 1 + for f in selection.state selection meta files aux; do + a="$snap/panels/$panel/$f"; b="$RT_PANEL_STAGE/$panel/$f" + if [ -e "$a" ] || [ -e "$b" ]; then + cmp -s "$a" "$b" || return 1 + fi + done + rt_transaction_stage_reset >/dev/null 2>&1 || true + return 0 +} + +rt_panel_manual_steps() { + # print what the operator must set in the panel when activation is manual. + case "$(rt_panel_current)" in + rebecca) + rt_info "In the Rebecca dashboard: Settings -> Subscription -> Templates" + rt_info " Subscription page template: row-template/index.html" + rt_info " Custom templates directory: ${RT_RB_DATA_DIR:-/var/lib/rebecca}/templates" + rt_info "If a custom templates directory is already set, keep it and copy" + rt_info " ${RT_RB_DATA_DIR:-/var/lib/rebecca}/templates/row-template/ into it instead." + rt_info "(Automatic activation needs the sqlite3 command and Rebecca's SQLite database.)" ;; + pasarguard) + rt_info "Copy $RT_LIVE to ${RT_PG_DATA_DIR:-/var/lib/pasarguard}/templates/row-template/index.html," + rt_info "then in ${RT_PG_APP_DIR:-/opt/pasarguard}/.env set these and run 'pasarguard restart':" + rt_info " CUSTOM_TEMPLATES_DIRECTORY = \"${RT_PG_DATA_DIR:-/var/lib/pasarguard}/templates\"" + rt_info " SUBSCRIPTION_PAGE_TEMPLATE = \"row-template/index.html\"" + rt_info "(Keep your own CUSTOM_TEMPLATES_DIRECTORY if you have one, and copy the page into it.)" ;; + *) + rt_info "In the panel: Settings -> Subscription -> Sub Theme Directory" + rt_info "Set it to exactly: $RT_ROOT" ;; + esac +} + # --- high-level flow: install ------------------------------------------------ # Called by installer/install.sh with a verified, extracted payload directory. # Runs the whole transaction: preflight -> stage -> validate -> backup -> @@ -2174,7 +3010,7 @@ rt_subtheme_clear_sqlite() { # panel: the live template is only ever swapped atomically after validation. rt_cmd_install() { - local payload="$1" w picked_explicit="" picked source + local payload="$1" w picked_explicit="" picked panel rt_require_root [ -n "$payload" ] && [ -d "$payload" ] || rt_die "internal: install payload directory missing." [ -f "$payload/template.html" ] || rt_die "install payload has no template.html." @@ -2187,11 +3023,27 @@ rt_cmd_install() { rt_validate_template "$payload/template.html" || rt_die "install artifact failed structural validation." rt_payload_companions_ok "$payload" || rt_die "the release payload is incomplete; nothing was changed." - # environment discovery + hard version gate (fail closed) - rt_detect_xui || rt_die "no 3x-ui installation was detected on this host." - rt_detect_xui_version >/dev/null 2>&1 || true - rt_check_min_version - rt_detect_xui_db || true + # Which panel: an existing install's, the operator's RT_PANEL, or the one on + # this host. This also decides the install root (rt_panel_choose). + rt_panel_choose || rt_die "nothing was changed." + panel="$RT_ACTIVE_PANEL" + # A panel can be present and still unable to serve this release's page + # (Rebecca's 0.0.x Python edition); refuse before anything is written. 3X-UI + # has no such check, and a 3X-UI install can run from the library alone + # (the panel adapters are companions it may not have yet). + if [ "$panel" != "3xui" ]; then + rt_panel_preflight "$panel" || rt_die "nothing was changed." + fi + + # environment discovery + hard version gate (fail closed). 3X-UI only: the + # other panels are identified by their adapter, and their activation does not + # depend on a panel version. + if [ "$panel" = "3xui" ]; then + rt_detect_xui || rt_die "no 3x-ui installation was detected on this host." + rt_detect_xui_version >/dev/null 2>&1 || true + rt_check_min_version + rt_detect_xui_db || true + fi rt_assert_not_symlink "$RT_ROOT" || rt_die "install root is a symlink; refusing to proceed." @@ -2212,7 +3064,7 @@ rt_cmd_install() { repair) rt_info "Repairing in place (configuration preserved)." ;; esac else - rt_install_welcome "${RT_XUI_VERSION:-}" || { rt_info "Installation cancelled."; return 0; } + rt_install_welcome "$panel" "${RT_XUI_VERSION:-}" || { rt_info "Installation cancelled."; return 0; } fi elif [ "$existing" -eq 1 ]; then if [ ! -t 0 ] && [ -z "${RT_ASSUME_YES:-}" ]; then @@ -2226,9 +3078,14 @@ rt_cmd_install() { rt_backup_create >/dev/null || rt_warn "could not create a pre-install backup." fi - # stage the canonical artifact + supporting files (all atomic, symlink-guarded) - rt_set_dist "$payload/template.html" || rt_die "could not install the canonical artifact." + # stage the canonical artifact + supporting files (all atomic, symlink-guarded). + # The payload's top-level template.html is the 3X-UI Row artifact; on the + # other panels the canonical artifact comes from their own store, below. + if [ "$panel" = "3xui" ]; then + rt_set_dist "$payload/template.html" || rt_die "could not install the canonical artifact." + fi rt_atomic_install "$payload/VERSION" "$RT_VERSION_FILE" 644 || rt_die "could not install VERSION." + rt_panel_record "$panel" || rt_die "could not record the panel this install serves." if [ -f "$payload/lib/row-template.sh" ]; then rt_atomic_install "$payload/lib/row-template.sh" "$RT_LIB_DIR/row-template.sh" 644 \ || rt_warn "could not install the management library; the CLI may be unavailable." @@ -2240,9 +3097,15 @@ rt_cmd_install() { || rt_warn "could not install the row-template CLI to $RT_BIN." fi - # template store: every design this release ships, verified before staging. - rt_stage_template_store "$payload" \ + # template store: every design this release ships for this panel, verified + # before staging, and any store a previous path mistake left outside + # dist/templates moved in. + local store_rc=0 + rt_repair_template_store "$payload" || store_rc=$? + [ "$store_rc" -ne 1 ] \ || rt_die "the release template store failed verification; nothing was activated." + rt_template_store_has row \ + || rt_die "this release carries no $(rt_panel_label "$panel") pages; nothing was activated." # the fresh-install design chooser (interactive only; defaults to Row). if [ "$interactive" -eq 1 ] && [ "$existing" -eq 0 ]; then @@ -2293,10 +3156,12 @@ rt_cmd_install() { # generate + validate + atomically swap the live template. rt_activate || rt_die "the template failed to generate/validate; the panel was not changed." - # point the panel at Row-Template. NON-INTERACTIVE: auto-configure exactly as - # before. INTERACTIVE: show the current subThemeDir and ASK before changing it. + # point the panel at Row-Template. NON-INTERACTIVE: activate exactly as + # before. INTERACTIVE: show what will change and ASK first. local sub_outcome - if [ "$interactive" -eq 1 ]; then + if [ "$panel" != "3xui" ]; then + sub_outcome="$(rt_install_activate_panel "$interactive")" + elif [ "$interactive" -eq 1 ]; then rt_ui_section "Activate Row-Template as the subscription theme" local sub_rc=0 sub_cur sub_cur="$(rt_subtheme_get_sqlite 2>/dev/null)" || sub_rc=$? @@ -2334,6 +3199,40 @@ rt_cmd_install() { rt_render_report } +rt_install_activate_panel() { + # INTERACTIVE ($1=1): show what activation changes, ask, then activate. + # Echo auto | manual | skipped | failed on stdout; everything else to stderr. + local interactive="$1" panel outcome + panel="$(rt_panel_current)" + if [ "$interactive" -eq 1 ]; then + { + rt_ui_section "Activate Row-Template on $(rt_panel_label "$panel")" + case "$panel" in + pasarguard) + rt_ui_info "This places the page in PasarGuard's templates directory, adds a" + rt_ui_info "Row-Template block to ${RT_PG_APP_DIR:-/opt/pasarguard}/.env selecting it, and" + rt_ui_info "restarts PasarGuard once. Your users, nodes and settings are not touched." + rt_ui_info "Uninstalling removes the block again." ;; + rebecca) + rt_ui_info "This places the page in Rebecca's templates directory and selects it" + rt_ui_info "in Rebecca's subscription settings. No restart is needed. Your users," + rt_ui_info "nodes and other settings are not touched." ;; + esac + } >&2 + if ! rt_ui_confirm "Make Row-Template the active $(rt_panel_label "$panel") subscription page now?" yes; then + printf 'skipped' + return 0 + fi + fi + if outcome="$(rt_panel_activate)"; then + printf '%s' "$outcome" + else + rt_warn "activation on $(rt_panel_label "$panel") did not complete; the panel was restored to how it was." + printf 'failed' + fi + return 0 +} + # --- high-level flow: config ------------------------------------------------- # Reconfigure branding. The new template is generated and validated BEFORE the # live file is swapped, and the canonical artifact is reconciled to the @@ -2346,6 +3245,9 @@ rt_cmd_config() { rt_require_root [ -f "$RT_DIST" ] || rt_die "Row-Template is not installed (run the installer first)." rt_detect_xui || true + # A branding write reconciles the selection against the template store, so an + # install left without one (v1.1.0's updater) is completed first. + rt_complete_install || true local saved="" distbak="" sumbak="" if [ -f "$RT_CONFIG" ]; then saved="$(mktemp)" || rt_die "cannot create a temp file." @@ -2376,8 +3278,10 @@ rt_cmd_config() { } # --- high-level flow: verify ------------------------------------------------- -# Read-only health report. Emits ok/warn/FAIL lines and returns non-zero only -# when a hard check fails. Never changes anything and never prints secrets. +# Health report. Emits ok/warn/FAIL lines and returns non-zero only when a hard +# check fails. Never prints secrets. Its only writes heal the template store +# (see rt_repair_template_store and rt_complete_install), and only when it can +# write to the install root; run without root it changes nothing. rt_cmd_verify() { local fails=0 warns=0 perm cur rc r rv sel_id store_n @@ -2393,18 +3297,52 @@ rt_cmd_verify() { else rt_err "canonical artifact missing checksum or does not match it."; fails=$((fails + 1)); fi else rt_err "canonical artifact missing or unreadable: $RT_DIST"; fails=$((fails + 1)); fi + # Before the store is judged it is healed, when verify can write (as root): + # a store left outside dist/templates is moved home, and designs or + # installer files the installed version ships but the host lacks (as v1.1.0's + # updater leaves it) are completed from that same release. These are the only + # writes verify makes; run without root it changes nothing and reports. + local repaired=0 repair_rc=0 rel id corrupt="" missing="" n_avail=0 n_missing=0 + if [ -f "$RT_DIST" ] && [ -d "$RT_ROOT" ] && [ ! -L "$RT_ROOT" ] && [ -w "$RT_ROOT" ]; then + repaired=1 + rt_repair_template_store || repair_rc=$? + [ "$repair_rc" -ne 1 ] || fails=$((fails + 1)) + if [ "$repair_rc" -eq 2 ] || ! rt_installer_complete; then + rt_complete_install || true + fi + fi + for rel in $RT_TEMPLATE_STORE_MISPLACED; do + [ -e "$RT_ROOT/$rel" ] || [ -L "$RT_ROOT/$rel" ] || continue + if [ "$repaired" -eq 0 ]; then + rt_warn "templates were found outside the store at $RT_ROOT/$rel; run 'row-template verify' as root to move them." + fi + warns=$((warns + 1)) + done + # The template store is the release's own copy of every selectable design. # Every artifact in it must match its sidecar, the stored selection must be # present, and the canonical artifact must be the selection's own bytes — # a config.env that names one design while another is live is the one state - # this system must never report as healthy. + # this system must never report as healthy. Each design is checked by name, + # so a missing or damaged one is reported as itself. sel_id="$(rt_template_effective)" store_n=0; [ -d "$RT_TEMPLATE_STORE" ] && store_n="$(rt_template_store_ids | grep -c . || true)" if [ "$store_n" -gt 0 ]; then - if rt_template_verify_store; then - rt_ok "Template store verified ($store_n design(s))." + for id in $RT_TEMPLATES_AVAILABLE; do + n_avail=$((n_avail + 1)) + case "$(rt_template_store_status "$id")" in + corrupt) corrupt="$corrupt $id" ;; + missing) missing="$missing $id"; n_missing=$((n_missing + 1)) ;; + esac + done + if [ -n "$corrupt" ]; then + rt_err "a template in the store does not match its checksum:$corrupt."; fails=$((fails + 1)) else - rt_err "a template in the store does not match its checksum."; fails=$((fails + 1)) + rt_ok "Template store verified ($store_n design(s))." + fi + if [ -n "$missing" ]; then + rt_warn "template store is incomplete ($((n_avail - n_missing)) of $n_avail designs); missing:$missing. Run 'row-template update' to restore them." + warns=$((warns + 1)) fi if rt_template_store_has "$sel_id"; then rt_ok "Template: $(rt_template_display_name "$sel_id")" @@ -2432,7 +3370,7 @@ rt_cmd_verify() { [ -r "$RT_CONFIG" ] && rt_ok "Config present and readable." \ || { rt_warn "config present but not readable."; warns=$((warns + 1)); } perm="$(stat -c '%a' "$RT_CONFIG" 2>/dev/null || true)" - if [ -n "$perm" ] && printf '%s' "$perm" | LC_ALL=C grep -qE '[2367]$'; then + if [ -n "$perm" ] && [[ "$perm" =~ [2367]$ ]]; then rt_warn "config.env is other-writable (mode $perm); tighten to 640."; warns=$((warns + 1)) fi else rt_info "No config.env (white-label defaults)."; fi @@ -2447,20 +3385,27 @@ rt_cmd_verify() { [ -x "$RT_BIN" ] && rt_ok "CLI present: $RT_BIN" \ || { rt_warn "CLI not found or not executable at $RT_BIN."; warns=$((warns + 1)); } - if rt_detect_xui; then - if rt_detect_xui_version >/dev/null 2>&1; then - if rt_semver_ge "$RT_XUI_VERSION" "$RT_MIN_XUI"; then rt_ok "3x-ui $RT_XUI_VERSION meets the minimum $RT_MIN_XUI." - else rt_err "3x-ui $RT_XUI_VERSION is below the minimum $RT_MIN_XUI."; fails=$((fails + 1)); fi - else rt_warn "could not determine the 3x-ui version."; warns=$((warns + 1)); fi - else rt_warn "3x-ui installation was not detected."; warns=$((warns + 1)); fi - - rt_detect_xui_db || true - rc=0; cur="$(rt_subtheme_get_sqlite)" || rc=$? - if [ "$rc" -eq 0 ]; then - if [ "$cur" = "$RT_ROOT" ]; then rt_ok "Panel subThemeDir points at Row-Template." - elif [ -z "$cur" ]; then rt_warn "panel subThemeDir is empty; set it to $RT_ROOT."; warns=$((warns + 1)) - else rt_warn "panel subThemeDir does not point at Row-Template."; warns=$((warns + 1)); fi - else rt_info "subThemeDir not checked (sqlite3/DB unavailable)."; fi + local vpanel + vpanel="$(rt_panel_current)" + if [ "$vpanel" = "3xui" ]; then + if rt_detect_xui; then + if rt_detect_xui_version >/dev/null 2>&1; then + if rt_semver_ge "$RT_XUI_VERSION" "$RT_MIN_XUI"; then rt_ok "3x-ui $RT_XUI_VERSION meets the minimum $RT_MIN_XUI." + else rt_err "3x-ui $RT_XUI_VERSION is below the minimum $RT_MIN_XUI."; fails=$((fails + 1)); fi + else rt_warn "could not determine the 3x-ui version."; warns=$((warns + 1)); fi + else rt_warn "3x-ui installation was not detected."; warns=$((warns + 1)); fi + + rt_detect_xui_db || true + rc=0; cur="$(rt_subtheme_get_sqlite)" || rc=$? + if [ "$rc" -eq 0 ]; then + if [ "$cur" = "$RT_ROOT" ]; then rt_ok "Panel subThemeDir points at Row-Template." + elif [ -z "$cur" ]; then rt_warn "panel subThemeDir is empty; set it to $RT_ROOT."; warns=$((warns + 1)) + else rt_warn "panel subThemeDir does not point at Row-Template."; warns=$((warns + 1)); fi + else rt_info "subThemeDir not checked (sqlite3/DB unavailable)."; fi + else + rt_verify_panel "$vpanel" || fails=$((fails + 1)) + warns=$((warns + RT_VERIFY_PANEL_WARNS)) + fi # Capture first rather than piping into `grep -q .`: under pipefail a match # would SIGPIPE `find` (rc 141) and the leftover-staging warning would be lost. @@ -2470,7 +3415,11 @@ rt_cmd_verify() { rt_warn "leftover staging files found under the install root (possible interrupted update)."; warns=$((warns + 1)) fi - r="$(rt_render_smoke)" + if [ "$vpanel" != "3xui" ] && [ -z "${RT_SMOKE_URL:-}" ]; then + r="skip" + else + r="$(rt_render_smoke)" + fi case "$r" in pass) rt_ok "Live render check: a browser receives Row-Template." ;; fallback) rt_warn "live render check: the panel served its built-in page."; warns=$((warns + 1)) ;; @@ -2493,6 +3442,47 @@ rt_cmd_verify() { else rt_ok "verification passed with no warnings."; return 0; fi } +RT_VERIFY_PANEL_WARNS=0 +rt_verify_panel() { + # verify's checks for PasarGuard and Rebecca. Sets RT_VERIFY_PANEL_WARNS; + # returns non-zero on a hard failure. Prints only facts, never a secret. + local panel="$1" st rc=0 + RT_VERIFY_PANEL_WARNS=0 + if ! rt_installer_complete; then + rt_err "the panel components are missing; run 'row-template update'." + return 1 + fi + if rt_panel_on_host "$panel"; then rt_ok "$(rt_panel_label "$panel") detected." + else rt_warn "$(rt_panel_label "$panel") was not detected on this host."; RT_VERIFY_PANEL_WARNS=$((RT_VERIFY_PANEL_WARNS + 1)); fi + st="$(rt_panel_status "$panel")" + case "$st" in + active) + rt_panel_verify "$panel" static || rc=$? + if [ "$rc" -eq 0 ]; then + rt_ok "$(rt_panel_label "$panel") selects the Row-Template page, and the placed page is current." + else + rt_err "$(rt_panel_label "$panel")'s Row-Template page is out of step (see above); re-apply it from the manager (Activate)." + return 1 + fi + rc=0; rt_panel_verify "$panel" live || rc=$? + case "$rc" in + 0) rt_ok "Live check: the running $(rt_panel_label "$panel") uses the Row-Template page." ;; + 2) rt_info "Live check skipped (not available for this panel layout)." ;; + *) rt_warn "the running $(rt_panel_label "$panel") does not use the Row-Template page yet; restart the panel." + RT_VERIFY_PANEL_WARNS=$((RT_VERIFY_PANEL_WARNS + 1)) ;; + esac ;; + inactive) + rt_warn "$(rt_panel_label "$panel") does not select the Row-Template page; activate it from the manager." + RT_VERIFY_PANEL_WARNS=$((RT_VERIFY_PANEL_WARNS + 1)) ;; + manual) + rt_info "Panel selection not checked (activation is manual here: $(rt_panel_label "$panel")'s database cannot be read)." ;; + *) + rt_warn "the $(rt_panel_label "$panel") configuration could not be read." + RT_VERIFY_PANEL_WARNS=$((RT_VERIFY_PANEL_WARNS + 1)) ;; + esac + return 0 +} + # --- high-level flow: uninstall ---------------------------------------------- # Conservative by construction: the install root is positively identified as # Row-Template's own before any recursive delete, and only files Row-Template @@ -2518,6 +3508,31 @@ rt_uninstall_files() { return 0 } +rt_uninstall_panel() { + # Put PasarGuard or Rebecca back to the page it had before Row-Template, and + # remove the page Row-Template placed. Returns non-zero only when the revert + # FAILED; "nothing to revert" and "must be reverted by hand" are reported and + # let the uninstall continue. + local panel="$1" rc=0 + if ! rt_installer_complete; then + rt_err "the panel components are missing, so $(rt_panel_label "$panel") cannot be reverted automatically; run 'row-template update' first." + return 1 + fi + rt_panel_uninstall_template "$panel" || rc=$? + case "$rc" in + 0) rt_ok "$(rt_panel_label "$panel") is back on the subscription page it had before Row-Template." ;; + 3) rt_info "$(rt_panel_label "$panel") was not using Row-Template; its selection was left as it is." ;; + 2) + rt_warn "$(rt_panel_label "$panel")'s selection cannot be changed automatically here." + case "$panel" in + rebecca) rt_info "In the Rebecca dashboard set Subscription page template back to subscription/index.html (or your own page)." ;; + *) rt_info "Select your previous subscription page in the panel." ;; + esac ;; + *) rt_err "could not revert $(rt_panel_label "$panel")."; return 1 ;; + esac + return 0 +} + rt_cmd_uninstall() { rt_require_root [ -f "$RT_VERSION_FILE" ] || rt_die "Row-Template does not appear to be installed at $RT_ROOT." @@ -2533,6 +3548,19 @@ rt_cmd_uninstall() { rt_detect_xui || true rt_detect_xui_db || true + local upanel + upanel="$(rt_panel_current)" + if [ "$upanel" != "3xui" ]; then + rt_uninstall_panel "$upanel" || rt_die "uninstall stopped before removing anything; the panel and Row-Template are unchanged." + if rt_uninstall_files; then + rt_ok "Removed Row-Template files from $RT_ROOT." + rt_info "$(rt_panel_label "$upanel")'s users, nodes, settings and database were left untouched." + else + rt_die "uninstall could not complete safely; see the message above. No forced deletion was performed." + fi + return 0 + fi + # revert the panel to a safe state: clear subThemeDir only if it points at us. local cur rc=0; cur="$(rt_subtheme_get_sqlite)" || rc=$? if [ "$rc" -eq 0 ] && [ "$cur" = "$RT_ROOT" ]; then @@ -2582,7 +3610,12 @@ rt_cmd_update() { # artifact so an OLDER installed library updating against this payload # degrades safely to Row; only the freshly staged library understands the # store, so the selection is resolved from it, never from the top-level file. - rt_stage_template_store "$payload" || rt_die "the release template store failed verification." + # A store a path mistake left outside dist/templates is moved in as well, so + # an update always ends with the one store the library reads. Designs still + # missing afterwards (a payload that ships no store) fall back below. + local store_rc=0 + rt_repair_template_store "$payload" || store_rc=$? + [ "$store_rc" -ne 1 ] || rt_die "the release template store failed verification." picked="$(rt_template_effective)" if rt_template_store_has "$picked"; then source="$RT_TEMPLATE_STORE/$picked/template.html" @@ -2591,8 +3624,10 @@ rt_cmd_update() { picked="row" if rt_template_store_has "row"; then source="$RT_TEMPLATE_STORE/row/template.html" - else + elif [ "$(rt_panel_current)" = "3xui" ]; then source="$payload/template.html" + else + rt_die "this release carries no $(rt_panel_label "$(rt_panel_current)") pages; nothing was changed." fi fi rt_validate_template "$source" || rt_die "the selected template failed structural validation." @@ -2682,7 +3717,7 @@ rt_cmd_rollback() { rt_print_help() { cat <<'EOF' -Row-Template — custom subscription page manager for 3x-ui +Row-Template — custom subscription page manager for 3X-UI, PasarGuard and Rebecca by iitzSeriZdev — https://github.com/iitzSeriZdev/Row-Template Usage: @@ -2693,8 +3728,9 @@ Commands: config Change the service name, support URL or logo, then regenerate update Download, verify and activate a newer release (checksum enforced) rollback Restore a previous version [--auto | --to ] - verify Check the install, panel wiring and live render (read-only) - version Show installed, minimum-supported and detected 3x-ui versions + verify Check the install, panel wiring and live render (as root, also + restores missing or misplaced designs) + version Show the installed version and panel (and, on 3X-UI, its version) uninstall Remove Row-Template and revert the panel to its built-in page menu Open the interactive manager explicitly help Show this help @@ -2789,6 +3825,17 @@ rt_status_theme() { if [ ! -f "$RT_DIST" ] || ! rt_validate_template "$RT_LIVE" >/dev/null 2>&1; then printf 'damaged'; return 0 fi + local panel + panel="$(rt_panel_current)" + if [ "$panel" != "3xui" ]; then + [ -n "${RT_PANELS_LOADED:-}" ] || { printf 'unknown'; return 0; } + case "$(rt_panel_status "$panel")" in + active) printf 'active' ;; + inactive) printf 'inactive' ;; + *) printf 'unknown' ;; + esac + return 0 + fi local rc=0 cur cur="$(rt_subtheme_get_sqlite 2>/dev/null)" || rc=$? if [ "$rc" -ne 0 ]; then printf 'unknown'; return 0; fi @@ -2807,6 +3854,18 @@ rt_status_label() { } rt_status_service_label() { # human label for the panel service, from discovery already run by the caller. + local panel + panel="$(rt_panel_current)" + if [ "$panel" != "3xui" ]; then + if declare -F "rt_panel_${panel}_running" >/dev/null && "rt_panel_${panel}_running" 2>/dev/null; then + printf '%s (running)' "$(rt_panel_label "$panel")" + elif rt_panel_on_host "$panel"; then + printf '%s (stopped)' "$(rt_panel_label "$panel")" + else + printf 'not detected' + fi + return 0 + fi if [ -n "${RT_XUI_UNIT:-}" ]; then if rt_service_active 2>/dev/null; then printf 'x-ui (running)'; else printf 'x-ui (stopped)'; fi else @@ -2818,7 +3877,12 @@ rt_status_theme_label() { case "$1" in active) printf 'Row-Template (active)' ;; inactive) printf 'Row-Template (installed, not the active theme)' ;; - unknown) printf 'Row-Template (installed; activation not verifiable without sqlite3)' ;; + unknown) + if [ "$(rt_panel_current)" = "3xui" ]; then + printf 'Row-Template (installed; activation not verifiable without sqlite3)' + else + printf 'Row-Template (installed; activation not verifiable here)' + fi ;; damaged) printf 'Row-Template (files incomplete — run Verify/Repair)' ;; *) printf 'Row-Template (not installed)' ;; esac @@ -2838,7 +3902,11 @@ rt_manager_dashboard() { st="$(rt_status_theme)" rt_ui_header rt_ui_kv "Version" "$rtv" - rt_ui_kv "3X-UI" "$xuiv" + if [ "$(rt_panel_current)" = "3xui" ]; then + rt_ui_kv "3X-UI" "$xuiv" + else + rt_ui_kv "Panel" "$(rt_panel_label "$(rt_panel_current)")" + fi rt_ui_kv "Status" "$(rt_status_label "$st")" rt_ui_kv "Template" "$(rt_template_display_name "$(rt_template_effective)")" rt_ui_kv "Theme" "$(rt_status_theme_label "$st")" @@ -2885,6 +3953,10 @@ rt_manager_activate() { # Detect the current subThemeDir, show it, and offer to point it at us. No # write happens unless the operator confirms; the panel state is preserved. rt_ui_section "Activate / Re-apply theme" + if [ "$(rt_panel_current)" != "3xui" ]; then + rt_manager_activate_panel + return 0 + fi rt_detect_xui >/dev/null 2>&1 || true rt_detect_xui_db >/dev/null 2>&1 || true local rc=0 cur @@ -2919,6 +3991,35 @@ rt_manager_activate() { rt_ui_kv "Enter exactly" "$RT_ROOT" fi } +rt_manager_activate_panel() { + # Activate / re-apply on PasarGuard or Rebecca. The page is regenerated first, + # so what is activated is exactly what verify will check. + local panel st outcome + panel="$(rt_panel_current)" + st="$(rt_panel_status "$panel")" + case "$st" in + active) + rt_ui_success "Row-Template is already the active $(rt_panel_label "$panel") subscription page." + rt_ui_confirm "Re-apply and verify anyway?" no || return 0 ;; + manual) + rt_ui_warn "Automatic activation is unavailable here; the page will be placed for you to select." ;; + *) + rt_ui_confirm "Make Row-Template the active $(rt_panel_label "$panel") subscription page now?" yes \ + || { rt_ui_info "Left unchanged."; return 0; } ;; + esac + rt_activate || { rt_ui_error "the page could not be generated; nothing was changed."; return 0; } + if outcome="$(rt_panel_activate)"; then + if [ "$outcome" = "manual" ]; then + rt_panel_manual_steps + else + rt_ui_success "Row-Template is active on $(rt_panel_label "$panel")." + rt_render_report + fi + else + rt_ui_warn "Activation did not complete; $(rt_panel_label "$panel") was restored to how it was." + fi +} + rt_manager_info() { # Non-sensitive install facts only. Never prints subscription URLs, subId, # UUIDs, panel credentials, DB secrets, tokens or the operator's support URL. @@ -2935,7 +4036,11 @@ rt_manager_info() { rt_ui_kv "Version" "$rtv" rt_ui_kv "Developer" "$RT_DEVELOPER" rt_ui_kv "GitHub" "$RT_GITHUB" - rt_ui_kv "3X-UI" "$xuiv" + if [ "$(rt_panel_current)" = "3xui" ]; then + rt_ui_kv "3X-UI" "$xuiv" + else + rt_ui_kv "Panel" "$(rt_panel_label "$(rt_panel_current)")" + fi rt_ui_kv "Install dir" "$RT_ROOT" rt_ui_kv "Template" "$(rt_template_display_name "$(rt_template_effective)")" rt_ui_kv "Theme status" "$(rt_status_label "$st")" @@ -3071,12 +4176,19 @@ rt_reconfig_template() { local list=() id prev cur choice n=0 i cur="$(rt_template_effective)" printf ' %sCurrent template:%s %s\n' "$RT_C_DIM" "$RT_C_RST" "$(rt_template_display_name "$cur")" + # designs the store should hold but does not are restored before the list is + # drawn (the manager also does this when it opens; this retries it, e.g. once + # the network is back) + if [ -n "$(rt_template_store_missing)" ]; then + rt_complete_install || true + fi while IFS= read -r id; do list+=("$id") done < <(rt_template_offered) n="${#list[@]}" if [ "$n" -eq 0 ]; then - rt_ui_warn "No templates are installed. Re-run the installer to restore the template store." + rt_ui_warn "No templates are installed, and they could not be restored automatically (see above)." + rt_ui_info "Open this menu again once the release source is reachable, or run 'row-template update'." return 0 fi @@ -3147,6 +4259,9 @@ rt_manager_main() { rt_detect_xui >/dev/null 2>&1 || true rt_detect_xui_version >/dev/null 2>&1 || true rt_detect_xui_db >/dev/null 2>&1 || true + # The first run after v1.1.0's updater finishes that update here: every design + # and installer file of the installed version, before anything is offered. + rt_complete_install || true local choice while true; do rt_manager_dashboard @@ -3171,12 +4286,20 @@ rt_manager_main() { # or alter a scripted install. rt_install_welcome() { - # $1 = detected 3x-ui version (may be empty). Returns 0 to proceed, 1 to abort. + # $1 = panel id, $2 = detected 3x-ui version (may be empty). Returns 0 to + # proceed, 1 to abort. + local panel="${1:-3xui}" rt_ui_header rt_ui_info "Welcome to the Row-Template installer." - rt_ui_kv "Detected 3X-UI" "${1:-unknown}" - rt_ui_info "Your panel data is safe: inbounds, clients, users and the panel" - rt_ui_info "database are NOT modified. Only a subscription theme is added." + if [ "$panel" = "3xui" ]; then + rt_ui_kv "Detected panel" "3X-UI ${2:-(version unknown)}" + rt_ui_info "Your panel data is safe: inbounds, clients, users and the panel" + rt_ui_info "database are NOT modified. Only a subscription theme is added." + else + rt_ui_kv "Detected panel" "$(rt_panel_label "$panel")" + rt_ui_info "Your panel data is safe: users, nodes, hosts and settings are NOT" + rt_ui_info "modified. Only a subscription page is added, and selected." + fi rt_ui_kv "GitHub" "$RT_GITHUB" printf '\n' rt_ui_confirm "Continue installation?" yes @@ -3258,15 +4381,21 @@ rt_install_success_screen() { name="$(rt_config_get_text SERVICE_NAME_B64 2>/dev/null || true)"; [ -n "$name" ] || name="(white-label)" rtv="$(rt_trim "$(cat "$RT_VERSION_FILE" 2>/dev/null || true)")"; [ -n "$rtv" ] || rtv="unknown" tpl="$(rt_template_display_name "$(rt_template_effective)")" + # On 3X-UI a declined activation is the manual step it has always been; on + # the other panels the manager's Activate does it later. + if [ "$outcome" = "skipped" ] && [ "$(rt_panel_current)" = "3xui" ]; then outcome="manual"; fi case "$outcome" in - auto) theme="Active" ;; - *) theme="Manual activation required" ;; + auto) theme="Active" ;; + skipped) theme="Not activated (run row-template -> Activate)" ;; + failed) theme="Activation failed and was rolled back" ;; + *) theme="Manual activation required" ;; esac printf '\n' rt_ui_rule printf ' %s%s installed%s\n' "$RT_C_GRN" "$RT_PROJECT_NAME" "$RT_C_RST" rt_ui_kv "Service" "$name" rt_ui_kv "Version" "$rtv" + rt_ui_kv "Panel" "$(rt_panel_label "$(rt_panel_current)")" rt_ui_kv "Template" "$tpl" rt_ui_kv "Install dir" "$RT_ROOT" rt_ui_kv "Theme" "$theme" @@ -3274,9 +4403,13 @@ rt_install_success_screen() { rt_ui_kv "GitHub" "$RT_GITHUB" rt_ui_kv "Developer" "$RT_DEVELOPER" rt_ui_rule - if [ "$theme" != "Active" ]; then - rt_ui_info "To activate: Panel Settings -> Subscription -> Sub Theme Directory" - rt_ui_kv "Enter exactly" "$RT_ROOT" + if [ "$outcome" = "manual" ]; then + if [ "$(rt_panel_current)" = "3xui" ]; then + rt_ui_info "To activate: Panel Settings -> Subscription -> Sub Theme Directory" + rt_ui_kv "Enter exactly" "$RT_ROOT" + else + rt_panel_manual_steps + fi fi } # --- panel interface (P3) ----------------------------------------------------- diff --git a/installer/lib/transaction.sh b/installer/lib/transaction.sh index 7df6be6..ba549e7 100644 --- a/installer/lib/transaction.sh +++ b/installer/lib/transaction.sh @@ -416,11 +416,29 @@ rt_transaction_rollback() { # each step protects the next: # 1. validate the snapshot -- before anything is touched; a malformed # snapshot must be refused, not half applied - # 2. rt_panel_restore_state -- the panel layer owns WHAT to restore and - # through which mechanism - # 3. static verification -- mandatory, and it is what makes "the restore - # worked" a checked claim - # 4. live verification -- optional evidence, never a failure here + # 2. rt_panel_restore_state -- the panel layer owns WHAT to restore, through + # which mechanism, and the verification that + # the restore actually landed + # 3. live verification -- optional evidence, never a failure here + # + # WHY THERE IS NO ENGINE-SIDE STATIC CHECK AFTER THE RESTORE (corrected in + # 1.3.0, and this was a real defect). The engine used to run the FORWARD + # static check here -- rt_panel_verify PANEL static -- and treat a non-zero + # result as "the rollback failed". That check answers "is Row-Template + # installed AND selected by this panel?". After a CORRECT rollback the answer + # is NO BY DESIGN: rollback takes Option A and restores the panel's PREVIOUS + # selection, so the panel deliberately stops pointing at Row-Template. The + # forward check therefore failed on every genuine rollback, the engine + # reported "rollback also failed", and a clean rollback was recorded as + # FAILED instead of ROLLED_BACK. + # + # The obligation to verify a restore belongs to the layer that owns the state + # model, and interface.sh already places it there: a panel's restore_state + # returns SUCCESS only when the operation completed AND its required + # verification passed. Every adapter therefore reads the state back and + # compares it with the record, and the engine checks THAT status. Asking the + # panel a forward question and calling a correct rollback a failure was the + # bug; re-asking it here would be the same bug again. # # This function deliberately does NOT touch selection, files or the service # itself. Those details live behind rt_panel_restore_state; duplicating them @@ -437,6 +455,12 @@ rt_transaction_rollback() { return "$RT_PANEL_FAIL" fi + # 2. The restore, and with it the rollback's required verification: a panel + # returns SUCCESS only after it has re-read the state it wrote and found it + # equal to the record. FAILURE and UNAVAILABLE both mean the panel was NOT + # returned to the recorded state, which is a failed rollback -- the two are + # not distinguished here because the recovery is the same (report, stop, + # attempt nothing further). rc=0 rt_panel_restore_state "$panel" "$snap" || rc=$? if [ "$rc" -ne "$RT_PANEL_OK" ]; then @@ -445,14 +469,6 @@ rt_transaction_rollback() { return "$RT_PANEL_FAIL" fi - rc=0 - rt_transaction_static_verify "$panel" || rc=$? - if [ "$rc" -ne "$RT_PANEL_OK" ]; then - rt_transaction_rollback_report_failure "$original" "post-restore static verification returned status $rc" - rt_transaction_state_set FAILED >/dev/null 2>&1 || true - return "$RT_PANEL_FAIL" - fi - # Live verification after a rollback is evidence, not a gate: UNAVAILABLE and # NOT_APPLICABLE are both acceptable here, and neither is reported as a pass. if rt_transaction_capability_present "${RT_TXN_CAPS:-}" live_verify; then diff --git a/installer/panels/3xui.sh b/installer/panels/3xui.sh index 07afe1d..02dc750 100644 --- a/installer/panels/3xui.sh +++ b/installer/panels/3xui.sh @@ -368,7 +368,7 @@ rt_panel_3xui_restore_state() { # # This function never calls the transaction engine, and never triggers a # second recovery. P4 owns rollback sequencing and its exactly-once rule. - local panel="$1" snap="$2" st mech was_running files rc=0 + local panel="$1" snap="$2" st mech was_running files rc=0 now nowval want # --- the record must be structurally complete and within its closed sets --- st="$(rt_backup_panel_state "$snap" "$panel")" || { @@ -422,6 +422,30 @@ rt_panel_3xui_restore_state() { || return "$RT_PANEL_FAIL" ;; esac + # --- the restore is not done until it is CHECKED --------------------------- + # interface.sh fixes the meaning of this function's SUCCESS: "operation + # completed AND its required verification passed". The transaction engine's + # rollback relies on exactly that and deliberately does NOT re-run a forward + # check of its own -- after a correct rollback this panel no longer selects + # Row-Template, so a forward check would fail by design and report a good + # rollback as a broken one. That makes the read-back below the WHOLE evidence + # that a rollback landed, so it compares the state and, for `present`, the + # value: a write that silently did not take, or took the wrong value, is a + # FAILURE and never a claim. + now="$(rt_panel_3xui_selection_state)" \ + || { rt_err "panel 3xui: cannot read the selection back after restoring"; return "$RT_PANEL_FAIL"; } + [ "$now" = "$st" ] || { + rt_err "panel 3xui: after restoring, the selection state is '$now', expected '$st'" + return "$RT_PANEL_FAIL"; } + if [ "$st" = "present" ]; then + want="$(rt_backup_panel_selection "$snap" "$panel")" + nowval="$(rt_panel_3xui_selection_value)" \ + || { rt_err "panel 3xui: cannot read the selection value back after restoring"; return "$RT_PANEL_FAIL"; } + [ "$nowval" = "$want" ] || { + rt_err "panel 3xui: after restoring, subThemeDir is not the recorded value" + return "$RT_PANEL_FAIL"; } + fi + # --- service state --------------------------------------------------------- if [ "$was_running" = "1" ]; then rt_panel_3xui_service_ensure running || return "$RT_PANEL_FAIL" diff --git a/installer/panels/index.sh b/installer/panels/index.sh index c7b8f3f..fad9bdd 100644 --- a/installer/panels/index.sh +++ b/installer/panels/index.sh @@ -9,15 +9,13 @@ # importantly — that P5 can add an implementation by changing this file alone, # without touching a single line of the contract. # -# TODAY THE ANSWER IS ALWAYS "NONE". P5 has not been written, so no panel has an -# implementation. This file does not pretend otherwise: it does not define a -# stub that returns SUCCESS, and it does not fall back to a generic -# implementation. Returning success for work that did not happen is the one -# failure mode a transaction engine cannot detect and cannot recover from. -# -# There are deliberately NO panel-specific files here (3xui.sh, pasarguard.sh, -# rebecca.sh). A file that exists is a file something can bind to; a stub that -# pretends to be an implementation is how a "temporary" shim becomes permanent. +# ALL THREE PANELS ARE IMPLEMENTED (3X-UI since P5A; PasarGuard and Rebecca +# since 1.3.0), each by one adapter file beside this one. The registry still +# never defines a stub that returns SUCCESS and never falls back to a generic +# implementation: a panel whose adapter file is absent or does not load is +# reported as having no implementation, which every verb answers UNAVAILABLE. +# Returning success for work that did not happen is the one failure mode a +# transaction engine cannot detect and cannot recover from. # --------------------------------------------------------------------------- # --- implementation files --------------------------------------------------- @@ -36,6 +34,8 @@ # an undefined function at call time, and "command not found" is an exit 127 # that no return-code contract describes. RT_PANEL_3XUI_LOADED="" +RT_PANEL_PASARGUARD_LOADED="" +RT_PANEL_REBECCA_LOADED="" rt_panel_registry_dir="$(dirname "${BASH_SOURCE[0]}")" if [ -f "$rt_panel_registry_dir/3xui.sh" ]; then if . "$rt_panel_registry_dir/3xui.sh"; then @@ -44,6 +44,20 @@ if [ -f "$rt_panel_registry_dir/3xui.sh" ]; then rt_err "panel registry: the 3xui adapter exists but could not be loaded" fi fi +if [ -f "$rt_panel_registry_dir/pasarguard.sh" ]; then + if . "$rt_panel_registry_dir/pasarguard.sh"; then + RT_PANEL_PASARGUARD_LOADED=1 + else + rt_err "panel registry: the pasarguard adapter exists but could not be loaded" + fi +fi +if [ -f "$rt_panel_registry_dir/rebecca.sh" ]; then + if . "$rt_panel_registry_dir/rebecca.sh"; then + RT_PANEL_REBECCA_LOADED=1 + else + rt_err "panel registry: the rebecca adapter exists but could not be loaded" + fi +fi unset rt_panel_registry_dir # --- implementation registry ----------------------------------------------- @@ -67,22 +81,14 @@ rt_panel_impl_for() { # differs per operation (UNAVAILABLE for most, NOT_APPLICABLE for detection). local panel="${1:-}" rt_panel_id_ok "$panel" || return 0 - # P5A: 3X-UI is implemented. It is reported ONLY when its adapter actually - # loaded, so a payload missing the file reports "none" rather than sending a - # caller to a function that is not there. - # - # The other two panels are still ABSENT from this mapping on purpose. They are - # not mapped to a function that reports success, and not mapped to a shared - # fallback: absence is the representation of "not implemented", and an absent - # key is impossible to mistake for a working one. + # Each panel is reported ONLY when its adapter actually loaded, so a payload + # missing a file reports "none" rather than sending a caller to a function + # that is not there. 3X-UI since P5A; PasarGuard and Rebecca since 1.3.0. case "$panel" in - 3xui) - if [ -n "${RT_PANEL_3XUI_LOADED:-}" ]; then - printf '%s\n' "3xui" - fi - return 0 ;; + 3xui) if [ -n "${RT_PANEL_3XUI_LOADED:-}" ]; then printf '%s\n' "3xui"; fi ;; + pasarguard) if [ -n "${RT_PANEL_PASARGUARD_LOADED:-}" ]; then printf '%s\n' "pasarguard"; fi ;; + rebecca) if [ -n "${RT_PANEL_REBECCA_LOADED:-}" ]; then printf '%s\n' "rebecca"; fi ;; esac - # P5B/P5C: add one line per implemented panel here. return 0 } @@ -132,6 +138,20 @@ rt_panel_dispatch() { 3xui:verify) rt_panel_3xui_verify "$panel" "$@" ;; 3xui:restore_state) rt_panel_3xui_restore_state "$panel" "$@" ;; 3xui:uninstall_template) rt_panel_3xui_uninstall_template "$panel" "$@" ;; + pasarguard:detect) rt_panel_pasarguard_detect "$panel" "$@" ;; + pasarguard:capabilities) rt_panel_pasarguard_capabilities "$panel" "$@" ;; + pasarguard:backup_state) rt_panel_pasarguard_backup_state "$panel" "$@" ;; + pasarguard:install_template) rt_panel_pasarguard_install_template "$panel" "$@" ;; + pasarguard:verify) rt_panel_pasarguard_verify "$panel" "$@" ;; + pasarguard:restore_state) rt_panel_pasarguard_restore_state "$panel" "$@" ;; + pasarguard:uninstall_template) rt_panel_pasarguard_uninstall_template "$panel" "$@" ;; + rebecca:detect) rt_panel_rebecca_detect "$panel" "$@" ;; + rebecca:capabilities) rt_panel_rebecca_capabilities "$panel" "$@" ;; + rebecca:backup_state) rt_panel_rebecca_backup_state "$panel" "$@" ;; + rebecca:install_template) rt_panel_rebecca_install_template "$panel" "$@" ;; + rebecca:verify) rt_panel_rebecca_verify "$panel" "$@" ;; + rebecca:restore_state) rt_panel_rebecca_restore_state "$panel" "$@" ;; + rebecca:uninstall_template) rt_panel_rebecca_uninstall_template "$panel" "$@" ;; *) rt_err "panel dispatch: no dispatch arm for implementation '$impl' verb '$verb'" return "$RT_PANEL_FAIL" ;; @@ -156,3 +176,54 @@ rt_panel_impl_install_template() { rt_panel_dispatch install_template "$@"; } rt_panel_impl_verify() { rt_panel_dispatch verify "$@"; } rt_panel_impl_restore_state() { rt_panel_dispatch restore_state "$@"; } rt_panel_impl_uninstall_template() { rt_panel_dispatch uninstall_template "$@"; } + +# --- outside the transaction (1.3.0) ----------------------------------------- +# Two helpers the MANAGEMENT commands use and the transaction engine never +# does. They are not a second way to perform any of the seven verbs above: +# +# rt_panel_refresh_page PANEL SOURCE [place] +# replace the page an adapter has placed (after a branding or design +# change) WITHOUT touching the panel's selection; `place` places it even +# when none is there yet (manual activation). 0 replaced, 3 nothing of +# ours is placed, 2 unavailable here, 1 failure. +# rt_panel_status PANEL +# echo active | inactive | manual | unknown, for the dashboard. +# +# 3X-UI places nothing: its page is served from the install root directly, so +# refresh is NOT_APPLICABLE and its status stays with rt_status_theme. +rt_panel_refresh_page() { + local panel="${1:-}" impl + rt_panel_id_ok "$panel" || return "$RT_PANEL_FAIL" + shift + impl="$(rt_panel_impl_for "$panel")" + case "$impl" in + pasarguard) rt_panel_pasarguard_refresh "$@" ;; + rebecca) rt_panel_rebecca_refresh "$@" ;; + 3xui) return "$RT_PANEL_NOT_APPLICABLE" ;; + *) return "$RT_PANEL_UNAVAILABLE" ;; + esac +} + +# Before an install or an activation changes anything: can this panel, as it is +# installed here, serve the page this release builds for it? 0 yes; 1 no, and +# the adapter has said why. (Rebecca's 0.0.x Python edition cannot.) +rt_panel_preflight() { + local panel="${1:-}" impl + rt_panel_id_ok "$panel" || return "$RT_PANEL_FAIL" + impl="$(rt_panel_impl_for "$panel")" + case "$impl" in + rebecca) rt_panel_rebecca_edition_ok ;; + *) return 0 ;; + esac +} + +rt_panel_status() { + local panel="${1:-}" impl + rt_panel_id_ok "$panel" || return "$RT_PANEL_FAIL" + impl="$(rt_panel_impl_for "$panel")" + case "$impl" in + pasarguard) rt_panel_pasarguard_status ;; + rebecca) rt_panel_rebecca_status ;; + *) printf 'unknown' ;; + esac +} diff --git a/installer/panels/pasarguard.sh b/installer/panels/pasarguard.sh new file mode 100644 index 0000000..f96dfac --- /dev/null +++ b/installer/panels/pasarguard.sh @@ -0,0 +1,703 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# installer/panels/pasarguard.sh -- the PasarGuard panel adapter (1.3.0). +# +# Sourced by installer/panels/index.sh and reached only through the seven +# public rt_panel_* verbs of installer/panels/interface.sh; the transaction +# engine never calls in here by name. +# +# WHAT PASARGUARD ACTIVATION ACTUALLY IS (audited against PasarGuard 5.x +# source and the official installer, docs/design/PASARGUARD-INSTALLER-AUDIT.md): +# +# * PasarGuard renders its subscription page with Jinja2 from a +# FileSystemLoader whose search path is [CUSTOM_TEMPLATES_DIRECTORY, +# app/templates], and picks the page by SUBSCRIPTION_PAGE_TEMPLATE. Both +# are read from the environment ONCE, at start-up. +# * The official installer runs it in Docker from /opt/pasarguard with +# `env_file: .env` and the bind mount /var/lib/pasarguard:/var/lib/pasarguard, +# so a path under /var/lib/pasarguard is the same path on the host and in +# the container. A source install runs it as pasarguard.service, reading +# .env from its working directory. +# +# So activation is two things, and this adapter does exactly those: +# +# 1. PLACE the generated page at /row-template/index.html, where +# is the operator's CUSTOM_TEMPLATES_DIRECTORY or, when there +# is none, /var/lib/pasarguard/templates. Only that one file is ever +# written, inside a directory named for Row-Template, and a file there +# that is not Row-Template's is never overwritten. +# 2. SELECT it by appending a MANAGED BLOCK to .env: +# +# # >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +# CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +# SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# # <<< row-template <<< +# +# dotenv (python-dotenv and Docker Compose alike) takes the LAST +# assignment of a key, so the block wins without a single operator line +# being edited. Removing the block is therefore the exact inverse of +# adding it: the file returns to its previous bytes, and any line the +# operator changed in the meantime is kept. CUSTOM_TEMPLATES_DIRECTORY is +# written only when the operator has not set one. `nl=1` records that a +# newline was added to a file that ended without one, so removal restores +# that too. +# +# Then the panel is restarted (docker compose up -d, which recreates the +# container because its environment changed), but only if it was running. +# Later page updates (a new design, new branding) replace the file alone: +# Jinja2 re-reads a changed template, so no further restart is needed. +# +# SECRETS. .env holds the panel's database URL, admin credentials and more. +# This adapter reads it only to find the two keys above; it never prints it, +# never copies it outside its own directory (the atomic rewrite stages a copy +# beside it, with the same mode), and never puts any of it in a snapshot. +# --------------------------------------------------------------------------- + +# The capability set, LC_ALL=C order, every token backed by code below. +RT_PANEL_PASARGUARD_CAPABILITIES="env_activation file_placement live_verify selection_read selection_write service_control static_verify" + +# Locations. Overridable (tests, a non-default APP_NAME), never derived from +# panel output. Read at call time, so a test may set them after sourcing. +: "${RT_PG_APP_DIR:=/opt/pasarguard}" +: "${RT_PG_DATA_DIR:=/var/lib/pasarguard}" +: "${RT_PG_CLI:=/usr/local/bin/pasarguard}" +: "${RT_PG_PROJECT:=pasarguard}" +: "${RT_PG_UNIT:=pasarguard.service}" + +RT_PG_SUBDIR="row-template" +RT_PG_PAGE="row-template/index.html" +RT_PG_KEY_PAGE="SUBSCRIPTION_PAGE_TEMPLATE" +RT_PG_KEY_DIR="CUSTOM_TEMPLATES_DIRECTORY" +RT_PG_BLOCK_OPEN="# >>> row-template (managed by Row-Template; do not edit)" +RT_PG_BLOCK_CLOSE="# <<< row-template <<<" + +# --- environment ------------------------------------------------------------ + +rt_panel_pasarguard_compose() { printf '%s' "$RT_PG_APP_DIR/docker-compose.yml"; } + +rt_panel_pasarguard_compose_ok() { + # 0 when the compose file exists and runs PasarGuard's own image. + local f; f="$(rt_panel_pasarguard_compose)" + [ -f "$f" ] && [ ! -L "$f" ] || return 1 + LC_ALL=C grep -Eq '^[[:space:]]*image:[[:space:]]*["'"'"']?(docker\.io/)?pasarguard/panel([:@"'"'"'[:space:]]|$)' "$f" 2>/dev/null +} + +rt_panel_pasarguard_unit_ok() { + # 0 when a pasarguard systemd unit is registered. No `grep -q` on a pipe: + # under pipefail, grep -q closing early SIGPIPEs systemctl (see rt_detect_xui). + command -v systemctl >/dev/null 2>&1 || return 1 + systemctl list-unit-files 2>/dev/null | LC_ALL=C grep "^${RT_PG_UNIT//./\\.}" >/dev/null 2>&1 +} + +rt_panel_pasarguard_mode() { + # docker | systemd | none. Docker wins: it is the official layout. + if rt_panel_pasarguard_compose_ok; then printf 'docker'; return 0; fi + if rt_panel_pasarguard_unit_ok; then printf 'systemd'; return 0; fi + printf 'none' +} + +rt_panel_pasarguard_env() { + # echo the .env the panel reads. Docker reads APP_DIR/.env (env_file); + # a source install reads .env from the unit's WorkingDirectory. + local wd + if [ "$(rt_panel_pasarguard_mode)" = "systemd" ]; then + wd="$(systemctl show -p WorkingDirectory --value "$RT_PG_UNIT" 2>/dev/null || true)" + if [ -n "$wd" ] && [ -f "$wd/.env" ]; then printf '%s' "$wd/.env"; return 0; fi + fi + printf '%s' "$RT_PG_APP_DIR/.env" +} + +rt_panel_pasarguard_env_ready() { + # 0 when the .env exists as a regular file we may edit. + local e; e="$(rt_panel_pasarguard_env)" + [ -f "$e" ] && [ ! -L "$e" ] +} + +# --- .env reading (as data; nothing is sourced or evaluated) ---------------- + +rt_panel_pasarguard_env_get() { + # rt_panel_pasarguard_env_get KEY [outside] + # + # Echo the value the panel will read for KEY (rt_dotenv_get: last assignment + # wins). With "outside", our managed block is ignored -- the operator's own + # value. Exit 0 with the value (possibly empty) when KEY is assigned, 3 when + # it is not: "unset" and "set to empty" are different facts, and a restore + # needs both. + local key="$1" outside="${2:-}" + if [ -n "$outside" ]; then + rt_dotenv_get "$(rt_panel_pasarguard_env)" "$key" "$RT_PG_BLOCK_OPEN" "$RT_PG_BLOCK_CLOSE" + else + rt_dotenv_get "$(rt_panel_pasarguard_env)" "$key" + fi +} + +rt_panel_pasarguard_block_state() { + # absent | present | malformed. Present means exactly one opening and one + # closing marker, in that order. Anything else is not ours to interpret. + local env n_open n_close + env="$(rt_panel_pasarguard_env)" + [ -f "$env" ] || { printf 'absent'; return 0; } + n_open="$(LC_ALL=C grep -Fc "$RT_PG_BLOCK_OPEN" "$env" 2>/dev/null || true)" + n_close="$(LC_ALL=C grep -Fxc "$RT_PG_BLOCK_CLOSE" "$env" 2>/dev/null || true)" + if [ "${n_open:-0}" = "0" ] && [ "${n_close:-0}" = "0" ]; then printf 'absent'; return 0; fi + if [ "$n_open" = "1" ] && [ "$n_close" = "1" ]; then + local lo lc + # grep -m1 stops at the first match itself: no pipe into head to be cut short + lo="$(LC_ALL=C grep -Fn -m1 "$RT_PG_BLOCK_OPEN" "$env" | cut -d: -f1)" + lc="$(LC_ALL=C grep -Fxn -m1 "$RT_PG_BLOCK_CLOSE" "$env" | cut -d: -f1)" + if [ -n "$lo" ] && [ -n "$lc" ] && [ "$lo" -lt "$lc" ]; then printf 'present'; return 0; fi + fi + printf 'malformed' +} + +rt_panel_pasarguard_block_value() { + # Echo KEY's value inside our block, empty when the block does not set it. + local key="$1" env + env="$(rt_panel_pasarguard_env)" + RT_K="$key" RT_BO="$RT_PG_BLOCK_OPEN" RT_BC="$RT_PG_BLOCK_CLOSE" LC_ALL=C awk ' + BEGIN { want = ENVIRON["RT_K"]; bo = ENVIRON["RT_BO"]; bc = ENVIRON["RT_BC"]; inb = 0 } + { line = $0; sub(/\r$/, "", line) } + index(line, bo) == 1 { inb = 1; next } + line == bc { inb = 0; next } + inb { + eq = index(line, "="); if (eq == 0) next + k = substr(line, 1, eq - 1); sub(/[ \t]+$/, "", k) + if (k != want) next + v = substr(line, eq + 1); sub(/^[ \t]+/, "", v); gsub(/"/, "", v) + printf "%s", v + } + ' "$env" 2>/dev/null || true +} + +# --- .env writing (atomic, mode-preserving, block only) --------------------- + +rt_panel_pasarguard_path_ok() { + # A path we are willing to write into .env: absolute, and only characters + # that need no quoting or escaping in any dotenv dialect. + case "${1:-}" in + /*) : ;; + *) return 1 ;; + esac + case "$1" in + *[!A-Za-z0-9._/-]*|*//*|*/../*|*/..|*/./*) return 1 ;; + esac + return 0 +} + +rt_panel_pasarguard_env_rewrite() { + # rt_panel_pasarguard_env_rewrite MODE [DIR] + # MODE=remove drop our block (restoring a missing final newline) + # MODE=write DIR|"" replace our block with one selecting our page; DIR + # is written as CUSTOM_TEMPLATES_DIRECTORY when given + # Staged beside the file with the file's own mode, then renamed over it, so + # the panel never reads a half-written .env. + local mode="$1" dir="${2:-}" env tmp nl=0 perm + env="$(rt_panel_pasarguard_env)" + [ -f "$env" ] && [ ! -L "$env" ] || { rt_err "panel pasarguard: .env is missing or a symlink: $env"; return 1; } + case "$(rt_panel_pasarguard_block_state)" in + malformed) rt_err "panel pasarguard: the Row-Template block in $env is damaged; fix or remove it by hand"; return 1 ;; + esac + perm="$(stat -c '%a' "$env" 2>/dev/null || echo 600)" + tmp="$(mktemp "$(dirname "$env")/.env.row-template.XXXXXX")" || return 1 + chmod 600 "$tmp" 2>/dev/null || true + + # 1. the file without our block, every other byte as it was -- including a + # last line that has no newline (awk would otherwise add one). A block + # written with nl=1 had itself added the newline before it; removing the + # block removes that newline again -- but only while the block is still + # the end of the file. A line the operator added after it owns it now. + local nonl=0 + if [ -s "$env" ] && [ "$(tail -c 1 "$env" | od -An -c | tr -d ' ')" != '\n' ]; then nonl=1; fi + RT_BO="$RT_PG_BLOCK_OPEN" RT_BC="$RT_PG_BLOCK_CLOSE" RT_NONL="$nonl" LC_ALL=C awk ' + BEGIN { bo = ENVIRON["RT_BO"]; bc = ENVIRON["RT_BC"]; nonl = (ENVIRON["RT_NONL"] == "1") + inb = 0; n = 0; strip = 0; closed = 0; lastkept = 0 } + { line = $0; raw = $0; sub(/\r$/, "", line) } + index(line, bo) == 1 { inb = 1; lastkept = 0; if (index(line, " nl=1 ") > 0) strip = 1; next } + line == bc { inb = 0; closed = 1; lastkept = 0; next } + inb { lastkept = 0; next } + closed { strip = 0 } + { buf[++n] = raw; lastkept = 1 } + END { + for (i = 1; i <= n; i++) { + if (i == n && (strip || (nonl && lastkept))) printf "%s", buf[i]; else printf "%s\n", buf[i] + } + } + ' "$env" > "$tmp" || { rm -f "$tmp"; return 1; } + + # 2. our block, appended. + if [ "$mode" = "write" ]; then + if [ -s "$tmp" ] && [ "$(tail -c 1 "$tmp" | od -An -c | tr -d ' ')" != '\n' ]; then + printf '\n' >> "$tmp"; nl=1 + fi + { + printf '%s nl=%s >>>\n' "$RT_PG_BLOCK_OPEN" "$nl" + [ -n "$dir" ] && printf '%s = "%s"\n' "$RT_PG_KEY_DIR" "$dir" + printf '%s = "%s"\n' "$RT_PG_KEY_PAGE" "$RT_PG_PAGE" + printf '%s\n' "$RT_PG_BLOCK_CLOSE" + } >> "$tmp" || { rm -f "$tmp"; return 1; } + fi + + chmod "$perm" "$tmp" 2>/dev/null || true + mv -f "$tmp" "$env" || { rm -f "$tmp"; return 1; } + return 0 +} + +# --- where the page goes ------------------------------------------------------ + +rt_panel_pasarguard_operator_dir() { + # The operator's own CUSTOM_TEMPLATES_DIRECTORY (outside our block), trailing + # slash removed; empty when unset or empty. + local v rc=0 + v="$(rt_panel_pasarguard_env_get "$RT_PG_KEY_DIR" outside)" || rc=$? + [ "$rc" -eq 0 ] || return 0 + v="${v%/}" + printf '%s' "$v" +} + +rt_panel_pasarguard_root() { + # Echo the templates root the page goes into: the operator's directory, else + # DATA_DIR/templates. In Docker the path must lie inside the bind-mounted + # DATA_DIR, the only place the host and the container see the same file; + # anything else is refused rather than guessed. + local root + root="$(rt_panel_pasarguard_operator_dir)" + [ -n "$root" ] || root="${RT_PG_DATA_DIR%/}/templates" + rt_panel_pasarguard_path_ok "$root" || { + rt_err "panel pasarguard: CUSTOM_TEMPLATES_DIRECTORY is not a plain absolute path: $root"; return 1; } + if [ "$(rt_panel_pasarguard_mode)" = "docker" ] && ! rt_is_within "$RT_PG_DATA_DIR" "$root"; then + rt_err "panel pasarguard: $root is outside $RT_PG_DATA_DIR, the directory the container shares with the host" + return 1 + fi + printf '%s' "$root" +} + +rt_panel_pasarguard_is_ours() { + # 0 when FILE is a page Row-Template generated for PasarGuard: our structural + # markers AND our prelude. Ownership is proven, never assumed. + local f="$1" + [ -f "$f" ] && [ ! -L "$f" ] || return 1 + LC_ALL=C grep -Fq '/* row:branding */' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq 'Row-Template -> PasarGuard page context' "$f" 2>/dev/null || return 1 + return 0 +} + +rt_panel_pasarguard_shell_ok() { + # 0 when FILE is a PasarGuard shell this release can serve safely: valid, with + # the context prelude and the autoescape block. A shell from a release before + # 1.3.0 has neither -- it would render empty and unescaped -- and is refused. + local f="$1" + rt_validate_template "$f" >/dev/null 2>&1 || return 1 + LC_ALL=C grep -Fq 'Row-Template -> PasarGuard page context' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq '{%- autoescape true -%}' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq '{%- endautoescape %}' "$f" 2>/dev/null || return 1 + return 0 +} + +# --- the service ---------------------------------------------------------------- + +rt_panel_pasarguard_container() { + # Echo the running panel container's name, or nothing. Found by its compose + # project label and its image, never by a name we assume. + command -v docker >/dev/null 2>&1 || return 0 + docker ps --filter "label=com.docker.compose.project=$RT_PG_PROJECT" --format '{{.Names}} {{.Image}}' 2>/dev/null \ + | LC_ALL=C awk '$2 ~ /^(docker\.io\/)?pasarguard\/panel([:@]|$)/ { print $1; exit }' || true +} + +rt_panel_pasarguard_running() { + case "$(rt_panel_pasarguard_mode)" in + docker) [ -n "$(rt_panel_pasarguard_container)" ] ;; + systemd) systemctl is-active --quiet "$RT_PG_UNIT" 2>/dev/null ;; + *) return 1 ;; + esac +} + +rt_panel_pasarguard_apply() { + # Make a changed .env take effect: recreate the container (compose sees the + # environment changed) or restart the unit. Only when the panel is running: + # a stopped panel picks the change up when its operator starts it. + rt_panel_pasarguard_running || return 0 + case "$(rt_panel_pasarguard_mode)" in + docker) + command -v docker >/dev/null 2>&1 || return 1 + rt_info "Restarting PasarGuard to apply the subscription page setting..." >&2 + docker compose -f "$(rt_panel_pasarguard_compose)" -p "$RT_PG_PROJECT" up -d >/dev/null 2>&1 || return 1 ;; + systemd) + rt_info "Restarting PasarGuard to apply the subscription page setting..." >&2 + systemctl restart "$RT_PG_UNIT" >/dev/null 2>&1 || return 1 ;; + esac + return 0 +} + +# --- what the database can still override (read-only) ------------------------ +# Two panel settings live in PasarGuard's database, not in .env, and both win +# over the selected page: an admin's own `sub_template` (their users get that +# page instead) and the subscription setting `disable_sub_template` (browsers +# get the raw subscription, no page at all). Row-Template never changes either +# -- they are the operator's choices -- but verify says when one applies, so a +# page that "does not show" is explained. Read-only, SQLite only; the database +# URL is read for its path and never printed (a server URL carries a password). + +rt_panel_pasarguard_db() { + # Echo the host path of PasarGuard's SQLite database, or fail. + local env url path rc=0 + env="$(rt_panel_pasarguard_env)" + [ -f "$env" ] && [ ! -L "$env" ] || return 1 + url="$(rt_dotenv_get "$env" SQLALCHEMY_DATABASE_URL)" || rc=$? + [ "$rc" -eq 0 ] && [ -n "$url" ] || return 1 + case "$url" in + sqlite:///*|sqlite+*:///*) path="${url#*:///}" ;; + *) return 1 ;; # PostgreSQL/MySQL: not read + esac + path="${path%%\?*}" + case "$path" in + /*) : ;; + *) [ "$(rt_panel_pasarguard_mode)" = "systemd" ] || return 1 # relative: inside the image + path="$(dirname -- "$env")/$path" ;; + esac + if [ "$(rt_panel_pasarguard_mode)" = "docker" ]; then + rt_is_within "$RT_PG_DATA_DIR" "$path" || return 1 + fi + rt_is_sqlite_db "$path" || return 1 + printf '%s' "$path" +} + +rt_panel_pasarguard_db_notes() { + # Warn (never fail) about the two overrides above. Silent when they cannot be + # read: this is advice, and verify's pass/fail does not depend on it. + local db n off + command -v sqlite3 >/dev/null 2>&1 || return 0 + db="$(rt_panel_pasarguard_db)" || return 0 + n="$(sqlite3 -readonly -cmd '.timeout 5000' "$db" \ + "SELECT COUNT(*) FROM admins WHERE sub_template IS NOT NULL AND sub_template <> '';" 2>/dev/null)" || n=0 + case "${n:-}" in ''|*[!0-9]*) n=0 ;; esac + [ "$n" = "0" ] \ + || rt_warn "panel pasarguard: $n admin(s) set their own subscription page (sub_template); their users keep that page." + off="$(sqlite3 -readonly -cmd '.timeout 5000' "$db" \ + "SELECT json_extract(subscription, '\$.disable_sub_template') FROM settings ORDER BY id LIMIT 1;" 2>/dev/null)" || off="" + case "$off" in + 1|true|True) + rt_warn "panel pasarguard: the panel's 'disable subscription template' setting is on, so browsers get the raw subscription instead of any page." ;; + esac + return 0 +} + +# --- the frozen verbs -------------------------------------------------------------- + +rt_panel_pasarguard_detect() { + # READ-ONLY. Two independent signals must agree (interface.sh): + # A the .env the official installer writes + # B a compose file running pasarguard/panel, or a pasarguard systemd unit + # C the pasarguard management CLI + # D the data directory + local signals=0 env + env="$RT_PG_APP_DIR/.env" + [ -f "$env" ] && [ ! -L "$env" ] && signals=$((signals + 1)) + if rt_panel_pasarguard_compose_ok || rt_panel_pasarguard_unit_ok; then signals=$((signals + 1)); fi + if [ -x "$RT_PG_CLI" ] && [ ! -d "$RT_PG_CLI" ] \ + && LC_ALL=C grep -q 'pasarguard' "$RT_PG_CLI" 2>/dev/null; then + signals=$((signals + 1)) + fi + [ -d "$RT_PG_DATA_DIR" ] && [ ! -L "$RT_PG_DATA_DIR" ] && signals=$((signals + 1)) + if [ "$signals" -ge 2 ]; then return "$RT_PANEL_OK"; fi + if [ "$signals" -eq 1 ]; then return "$RT_PANEL_FAIL"; fi + return "$RT_PANEL_NOT_APPLICABLE" +} + +rt_panel_pasarguard_capabilities() { + local t + for t in $RT_PANEL_PASARGUARD_CAPABILITIES; do printf '%s\n' "$t"; done + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_backup_state() { + # Stage the pre-change state through the P2 writer: + # selection the effective SUBSCRIPTION_PAGE_TEMPLATE (absent|empty|present) + # meta mechanism=env, was_running + # files the page, when THIS change will create it (a page that is + # already there and ours is replaced in place, not recorded) + # aux block=, dir=, and + # root_created=1 when the templates root does not exist yet + local panel="$1" state value="" rc=0 was_running=0 root page block files=() + rt_panel_pasarguard_env_ready || return "$RT_PANEL_UNAVAILABLE" + root="$(rt_panel_pasarguard_root)" || return "$RT_PANEL_FAIL" + block="$(rt_panel_pasarguard_block_state)" + [ "$block" = "malformed" ] && { rt_err "panel pasarguard: the Row-Template block in .env is damaged"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_running && was_running=1 + + value="$(rt_panel_pasarguard_env_get "$RT_PG_KEY_PAGE")" || rc=$? + if [ "$rc" -eq 3 ]; then state="absent"; value="" + elif [ "$rc" -ne 0 ]; then return "$RT_PANEL_FAIL" + elif [ -z "$value" ]; then state="empty" + else state="present"; fi + + page="$root/$RT_PG_PAGE" + [ -e "$page" ] || [ -L "$page" ] || files+=("$RT_PG_PAGE") + + rt_backup_panel_write "$RT_PANEL_STAGE" "$panel" "$state" "$value" env "$was_running" ${files[@]+"${files[@]}"} \ + || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" block "$block" || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" dir "$(rt_panel_pasarguard_block_value "$RT_PG_KEY_DIR")" \ + || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" root "$root" || return "$RT_PANEL_FAIL" + if [ ! -d "$root" ]; then + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" root_created 1 || return "$RT_PANEL_FAIL" + fi + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_place() { + # Copy SRC to /row-template/index.html atomically. Refuses a symlinked + # directory, and a page there that is not Row-Template's. + local src="$1" root="$2" dir dest tmp + dir="$root/$RT_PG_SUBDIR"; dest="$root/$RT_PG_PAGE" + [ -L "$root" ] && { rt_err "panel pasarguard: the templates directory is a symlink: $root"; return 1; } + [ -L "$dir" ] && { rt_err "panel pasarguard: $dir is a symlink"; return 1; } + if [ -e "$dest" ] || [ -L "$dest" ]; then + rt_panel_pasarguard_is_ours "$dest" \ + || { rt_err "panel pasarguard: $dest exists and is not Row-Template's; it was left untouched"; return 1; } + fi + mkdir -p "$dir" || return 1 + chmod 755 "$dir" 2>/dev/null || true + tmp="$(mktemp "$dir/.index.XXXXXX")" || return 1 + cp -- "$src" "$tmp" && chmod 644 "$tmp" && mv -f "$tmp" "$dest" || { rm -f "$tmp"; return 1; } + return 0 +} + +rt_panel_pasarguard_install_template() { + # Place SOURCE (the generated page) and select it. Idempotent: on a panel + # where Row-Template is already selected only the page is replaced, and the + # panel is not restarted. + local panel="$1" src="$2" root dir_value="" before after + [ -n "$src" ] || { rt_err "panel pasarguard: SOURCE is required"; return "$RT_PANEL_FAIL"; } + [ -L "$src" ] && { rt_err "panel pasarguard: refusing a symlinked SOURCE"; return "$RT_PANEL_FAIL"; } + [ -f "$src" ] || { rt_err "panel pasarguard: SOURCE is not a regular file: $src"; return "$RT_PANEL_FAIL"; } + rt_is_within "$RT_ROOT" "$src" || { rt_err "panel pasarguard: SOURCE is outside $RT_ROOT"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_shell_ok "$src" || { + rt_err "panel pasarguard: SOURCE is not a PasarGuard page this release can serve"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_env_ready || return "$RT_PANEL_UNAVAILABLE" + root="$(rt_panel_pasarguard_root)" || return "$RT_PANEL_FAIL" + + rt_panel_pasarguard_place "$src" "$root" || return "$RT_PANEL_FAIL" + + [ -n "$(rt_panel_pasarguard_operator_dir)" ] || dir_value="$root" + before="$(rt_panel_pasarguard_block_state):$(rt_panel_pasarguard_block_value "$RT_PG_KEY_DIR")" + after="present:$dir_value" + if [ "$before" != "$after" ] || [ "$(rt_panel_pasarguard_env_get "$RT_PG_KEY_PAGE" || true)" != "$RT_PG_PAGE" ]; then + rt_panel_pasarguard_env_rewrite write "$dir_value" || return "$RT_PANEL_FAIL" + rt_panel_pasarguard_apply || { rt_err "panel pasarguard: the panel could not be restarted"; return "$RT_PANEL_FAIL"; } + fi + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_selected() { + # 0 when .env (as the panel will read it) selects our page from our root. + local root page dir + [ "$(rt_panel_pasarguard_block_state)" = "present" ] || return 1 + page="$(rt_panel_pasarguard_env_get "$RT_PG_KEY_PAGE" 2>/dev/null)" || return 1 + [ "$page" = "$RT_PG_PAGE" ] || return 1 + root="$(rt_panel_pasarguard_root 2>/dev/null)" || return 1 + dir="$(rt_panel_pasarguard_env_get "$RT_PG_KEY_DIR" 2>/dev/null || true)" + [ "${dir%/}" = "$root" ] || return 1 + return 0 +} + +rt_panel_pasarguard_verify() { + # static: the canonical shell, the generated page, the placed copy and the + # selection all agree. live: the RUNNING container reads our + # selection and can see the page (Docker only). + local panel="$1" mode="$2" root dest want name page + case "$mode" in + static|live) : ;; + *) rt_err "panel pasarguard: unknown verification mode '$mode'"; return "$RT_PANEL_FAIL" ;; + esac + if [ "$mode" = "live" ]; then + [ "$(rt_panel_pasarguard_mode)" = "docker" ] || return "$RT_PANEL_UNAVAILABLE" + name="$(rt_panel_pasarguard_container)" + [ -n "$name" ] || return "$RT_PANEL_UNAVAILABLE" + root="$(rt_panel_pasarguard_root 2>/dev/null)" || return "$RT_PANEL_FAIL" + page="$(docker exec "$name" printenv "$RT_PG_KEY_PAGE" 2>/dev/null || true)" + [ "$page" = "$RT_PG_PAGE" ] || { rt_err "panel pasarguard: the running panel does not use the Row-Template page yet (restart it)"; return "$RT_PANEL_FAIL"; } + docker exec "$name" test -f "$root/$RT_PG_PAGE" >/dev/null 2>&1 \ + || { rt_err "panel pasarguard: the running panel cannot see $root/$RT_PG_PAGE"; return "$RT_PANEL_FAIL"; } + return "$RT_PANEL_OK" + fi + + [ -f "$RT_DIST" ] || { rt_err "panel pasarguard: the artifact is missing: $RT_DIST"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_shell_ok "$RT_DIST" \ + || { rt_err "panel pasarguard: the artifact is not a valid PasarGuard page"; return "$RT_PANEL_FAIL"; } + if [ -f "$RT_DIST_SUM" ]; then + want="$(LC_ALL=C awk '{print $1; exit}' "$RT_DIST_SUM" 2>/dev/null || true)" + rt_verify_sha256 "$RT_DIST" "$want" >/dev/null 2>&1 \ + || { rt_err "panel pasarguard: the artifact does not match its recorded checksum"; return "$RT_PANEL_FAIL"; } + fi + rt_validate_template "$RT_LIVE" >/dev/null 2>&1 \ + || { rt_err "panel pasarguard: the generated page is missing or invalid: $RT_LIVE"; return "$RT_PANEL_FAIL"; } + root="$(rt_panel_pasarguard_root)" || return "$RT_PANEL_FAIL" + dest="$root/$RT_PG_PAGE" + rt_panel_pasarguard_is_ours "$dest" \ + || { rt_err "panel pasarguard: the page is not in place: $dest"; return "$RT_PANEL_FAIL"; } + [ "$(rt_sha256 "$dest" 2>/dev/null || true)" = "$(rt_sha256 "$RT_LIVE" 2>/dev/null || true)" ] \ + || { rt_err "panel pasarguard: the placed page differs from the generated one"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_selected \ + || { rt_err "panel pasarguard: .env does not select the Row-Template page"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_db_notes + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_remove_page() { + # Remove our page from ROOT, then our directory and the root itself only when + # each is left empty (the root only when ROOT_CREATED says we made it). + local root="$1" root_created="${2:-0}" dest + dest="$root/$RT_PG_PAGE" + if [ -e "$dest" ] || [ -L "$dest" ]; then + rt_panel_pasarguard_is_ours "$dest" || { rt_warn "panel pasarguard: $dest is not Row-Template's; left in place"; return 0; } + rm -f -- "$dest" || return 1 + fi + rmdir -- "$root/$RT_PG_SUBDIR" 2>/dev/null || true + if [ "$root_created" = "1" ]; then rmdir -- "$root" 2>/dev/null || true; fi + return 0 +} + +rt_panel_pasarguard_restore_state() { + # Validate the record, put .env's block back the way it was, remove the page + # this change created, restore the running/stopped state. + local panel="$1" snap="$2" st mech was_running files f block dir root created + local rc now_block now_page want_page + st="$(rt_backup_panel_state "$snap" "$panel")" || { rt_err "panel pasarguard: malformed selection.state"; return "$RT_PANEL_FAIL"; } + case "$st" in absent|empty|present) : ;; *) return "$RT_PANEL_FAIL" ;; esac + rt_backup_panel_meta_check "$snap" "$panel" || { rt_err "panel pasarguard: malformed panel meta"; return "$RT_PANEL_FAIL"; } + mech="$(rt_manifest_get mechanism "$snap/panels/$panel/meta")" + [ "$mech" = "env" ] || { rt_err "panel pasarguard: mechanism is '$mech', expected 'env'"; return "$RT_PANEL_FAIL"; } + was_running="$(rt_manifest_get was_running "$snap/panels/$panel/meta")" + files="$(rt_backup_panel_files "$snap" "$panel")" || { rt_err "panel pasarguard: malformed files record"; return "$RT_PANEL_FAIL"; } + for f in $files; do + [ "$f" = "$RT_PG_PAGE" ] || { rt_err "panel pasarguard: refusing to restore: the record lists a file this adapter never places: $f"; return "$RT_PANEL_FAIL"; } + done + block="$(rt_backup_panel_aux "$snap" "$panel" block)" || return "$RT_PANEL_FAIL" + case "$block" in absent|present) : ;; *) rt_err "panel pasarguard: malformed block record"; return "$RT_PANEL_FAIL" ;; esac + dir="$(rt_backup_panel_aux "$snap" "$panel" dir)" || return "$RT_PANEL_FAIL" + root="$(rt_backup_panel_aux "$snap" "$panel" root)" || return "$RT_PANEL_FAIL" + created="$(rt_backup_panel_aux "$snap" "$panel" root_created)" || return "$RT_PANEL_FAIL" + rt_panel_pasarguard_path_ok "$root" || { rt_err "panel pasarguard: malformed root record"; return "$RT_PANEL_FAIL"; } + [ -z "$dir" ] || rt_panel_pasarguard_path_ok "$dir" || { rt_err "panel pasarguard: malformed dir record"; return "$RT_PANEL_FAIL"; } + + rt_panel_pasarguard_env_ready || return "$RT_PANEL_UNAVAILABLE" + if [ "$block" = "absent" ]; then + rt_panel_pasarguard_env_rewrite remove || return "$RT_PANEL_FAIL" + else + rt_panel_pasarguard_env_rewrite write "$dir" || return "$RT_PANEL_FAIL" + fi + + # --- the restore is not done until it is CHECKED --------------------------- + # interface.sh fixes the meaning of this function's SUCCESS: "operation + # completed AND its required verification passed". The transaction engine's + # rollback relies on exactly that and deliberately does NOT re-run a forward + # check of its own -- after a correct rollback this panel no longer selects + # Row-Template, so a forward check would fail by design and report a good + # rollback as a broken one. This comparison with the record is therefore the + # whole evidence that the rollback landed. + # + # The block's shape is checked first, then the effective value the panel will + # read for the page key. "unset" and "set to empty" are different facts (the + # P2 record distinguishes absent from empty), so the exit status is compared + # too, not only the text. + now_block="$(rt_panel_pasarguard_block_state)" + [ "$now_block" = "$block" ] || { + rt_err "panel pasarguard: after restoring, the Row-Template block is '$now_block', expected '$block'" + return "$RT_PANEL_FAIL"; } + if [ "$block" = "present" ]; then + [ "$(rt_panel_pasarguard_block_value "$RT_PG_KEY_DIR")" = "$dir" ] || { + rt_err "panel pasarguard: after restoring, the block's directory is not the recorded value" + return "$RT_PANEL_FAIL"; } + fi + want_page="" + if [ "$st" = "present" ]; then want_page="$(rt_backup_panel_selection "$snap" "$panel")"; fi + rc=0 + now_page="$(rt_panel_pasarguard_env_get "$RT_PG_KEY_PAGE")" || rc=$? + case "$st" in + absent) + [ "$rc" -eq 3 ] || { + rt_err "panel pasarguard: after restoring, $RT_PG_KEY_PAGE is set, but the record says it was unset" + return "$RT_PANEL_FAIL"; } ;; + *) + [ "$rc" -eq 0 ] && [ "$now_page" = "$want_page" ] || { + rt_err "panel pasarguard: after restoring, $RT_PG_KEY_PAGE is not the recorded value" + return "$RT_PANEL_FAIL"; } ;; + esac + + if [ -n "$files" ]; then + rt_panel_pasarguard_remove_page "$root" "${created:-0}" || return "$RT_PANEL_FAIL" + fi + if [ "$was_running" = "1" ]; then + rt_panel_pasarguard_apply || return "$RT_PANEL_FAIL" + fi + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_uninstall_template() { + # Remove our block and our page; restart the panel if it is running so it + # goes back to the page it had. NOT_APPLICABLE when neither is present. + local root block dest removed=0 created=0 + rt_panel_pasarguard_env_ready || return "$RT_PANEL_UNAVAILABLE" + block="$(rt_panel_pasarguard_block_state)" + [ "$block" = "malformed" ] && { rt_err "panel pasarguard: the Row-Template block in .env is damaged; fix or remove it by hand"; return "$RT_PANEL_FAIL"; } + root="$(rt_panel_pasarguard_root 2>/dev/null)" || root="" + # the root our block pointed at is the one the page lives in + if [ "$block" = "present" ]; then + local d; d="$(rt_panel_pasarguard_block_value "$RT_PG_KEY_DIR")" + if [ -n "$d" ] && rt_panel_pasarguard_path_ok "$d"; then root="$d"; created=1; fi + fi + if [ "$block" = "present" ]; then + rt_panel_pasarguard_env_rewrite remove || return "$RT_PANEL_FAIL" + removed=1 + fi + if [ -n "$root" ]; then + dest="$root/$RT_PG_PAGE" + if rt_panel_pasarguard_is_ours "$dest"; then + rt_panel_pasarguard_remove_page "$root" "$created" || return "$RT_PANEL_FAIL" + removed=1 + fi + fi + [ "$removed" -eq 1 ] || return "$RT_PANEL_NOT_APPLICABLE" + rt_panel_pasarguard_apply || { rt_err "panel pasarguard: the panel could not be restarted"; return "$RT_PANEL_FAIL"; } + return "$RT_PANEL_OK" +} + +# --- outside the transaction: refresh and status ---------------------------- +# Not part of the seven transactional verbs. `refresh` replaces the page after +# the operator changes branding or design -- the selection is not touched, so +# an operator who deselected Row-Template stays deselected. `status` is for the +# dashboard. Both are reached through rt_panel_refresh_page / rt_panel_status +# in installer/panels/index.sh. + +rt_panel_pasarguard_refresh() { + # rt_panel_pasarguard_refresh SOURCE [place] + # 0 page replaced | 3 no page of ours is placed (and `place` not given) | + # 2 .env unavailable | 1 failure + local src="$1" place="${2:-}" root + rt_panel_pasarguard_env_ready || return "$RT_PANEL_UNAVAILABLE" + rt_panel_pasarguard_shell_ok "$src" || return "$RT_PANEL_FAIL" + # Before activation there is nothing to refresh -- and no reason to resolve + # (let alone reject) a templates directory Row-Template has not used yet. + if [ -z "$place" ] && [ "$(rt_panel_pasarguard_block_state)" != "present" ]; then + return "$RT_PANEL_NOT_APPLICABLE" + fi + root="$(rt_panel_pasarguard_root)" || return "$RT_PANEL_FAIL" + if [ -z "$place" ] && ! rt_panel_pasarguard_is_ours "$root/$RT_PG_PAGE"; then + return "$RT_PANEL_NOT_APPLICABLE" + fi + rt_panel_pasarguard_place "$src" "$root" || return "$RT_PANEL_FAIL" + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_status() { + # active | inactive | unknown + local root + rt_panel_pasarguard_env_ready || { printf 'unknown'; return 0; } + root="$(rt_panel_pasarguard_root 2>/dev/null)" || { printf 'unknown'; return 0; } + if rt_panel_pasarguard_is_ours "$root/$RT_PG_PAGE" && rt_panel_pasarguard_selected; then + printf 'active' + else + printf 'inactive' + fi +} diff --git a/installer/panels/rebecca.sh b/installer/panels/rebecca.sh new file mode 100644 index 0000000..1aca963 --- /dev/null +++ b/installer/panels/rebecca.sh @@ -0,0 +1,574 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# installer/panels/rebecca.sh -- the Rebecca panel adapter (1.3.0). +# +# Sourced by installer/panels/index.sh and reached only through the seven +# public rt_panel_* verbs of installer/panels/interface.sh. +# +# WHAT REBECCA ACTIVATION ACTUALLY IS (audited against the Rebecca Go source +# and its official installer, docs/design/REBECCA-INSTALLER-AUDIT.md): +# +# * Rebecca renders its subscription page with pongo2. On EVERY request it +# reads the page's name and an optional custom directory from the newest +# row of its `subscription_settings` table: +# +# subscription_page_template default 'subscription/index.html' +# custom_templates_directory default NULL +# +# and reads the file / +# from disk (falling back to its bundled templates). Nothing is cached, so a +# change takes effect on the next request: no restart, ever. +# * The official installer runs it from /opt/rebecca, in Docker with the bind +# mount /var/lib/rebecca:/var/lib/rebecca, or as rebecca.service (binary +# mode). Its database is SQLite at /var/lib/rebecca/db.sqlite3 by default +# (SQLALCHEMY_DATABASE_URL in /opt/rebecca/.env), or MySQL/MariaDB. +# +# So activation is: +# +# 1. PLACE the generated page at /row-template/index.html, where is +# the operator's custom_templates_directory, else /var/lib/rebecca/templates. +# 2. SELECT it: set subscription_page_template = 'row-template/index.html', +# and custom_templates_directory = when the operator had none. Only +# that one row, only those two columns. +# +# Step 2 needs the sqlite3 command and a SQLite database. With MySQL/MariaDB, or +# without sqlite3, the adapter answers UNAVAILABLE and the installer prints the +# two values to enter in Rebecca's dashboard -- the same "manual activation" +# 3X-UI has without sqlite3. The adapter never asks for, reads or prints a +# database password: .env is read for one key, SQLALCHEMY_DATABASE_URL, and only +# a sqlite: URL is ever used. +# +# Per-administrator overrides. Rebecca lets an admin override both columns for +# their own users (admins.subscription_settings). Those users keep the admin's +# page; verify reports how many admins have one. +# --------------------------------------------------------------------------- + +RT_PANEL_REBECCA_CAPABILITIES="db_activation file_placement selection_read selection_write static_verify" + +: "${RT_RB_APP_DIR:=/opt/rebecca}" +: "${RT_RB_DATA_DIR:=/var/lib/rebecca}" +: "${RT_RB_CLI:=/usr/local/bin/rebecca}" +: "${RT_RB_PROJECT:=rebecca}" +: "${RT_RB_UNIT:=rebecca.service}" + +RT_RB_SUBDIR="row-template" +RT_RB_PAGE="row-template/index.html" +RT_RB_DEFAULT_PAGE="subscription/index.html" + +# --- environment ------------------------------------------------------------ + +rt_panel_rebecca_env() { printf '%s' "$RT_RB_APP_DIR/.env"; } + +rt_panel_rebecca_compose_ok() { + local f="$RT_RB_APP_DIR/docker-compose.yml" + [ -f "$f" ] && [ ! -L "$f" ] || return 1 + LC_ALL=C grep -Eq '^[[:space:]]*image:[[:space:]]*["'"'"']?(docker\.io/)?rebeccapanel/rebecca([:@"'"'"'[:space:]]|$)' "$f" 2>/dev/null +} + +rt_panel_rebecca_unit_ok() { + command -v systemctl >/dev/null 2>&1 || return 1 + systemctl list-unit-files 2>/dev/null | LC_ALL=C grep "^${RT_RB_UNIT//./\\.}" >/dev/null 2>&1 +} + +rt_panel_rebecca_mode() { + if rt_panel_rebecca_compose_ok; then printf 'docker'; return 0; fi + if rt_panel_rebecca_unit_ok; then printf 'binary'; return 0; fi + printf 'none' +} + +rt_panel_rebecca_running() { + case "$(rt_panel_rebecca_mode)" in + docker) + command -v docker >/dev/null 2>&1 || return 1 + [ -n "$(docker ps --filter "label=com.docker.compose.project=$RT_RB_PROJECT" --format '{{.Image}}' 2>/dev/null \ + | LC_ALL=C awk '/^(docker\.io\/)?rebeccapanel\/rebecca([:@]|$)/ { print; exit }' || true)" ] ;; + binary) systemctl is-active --quiet "$RT_RB_UNIT" 2>/dev/null ;; + *) return 1 ;; + esac +} + +# --- which Rebecca ---------------------------------------------------------------- +# Two different programs are published as Rebecca. 1.x is the Go edition: it +# renders the page with pongo2, from the page context this release's Rebecca +# page is built for, and ships as a binary (rebecca-binary.sh). But Docker Hub's +# rebeccapanel/rebecca:latest -- what rebecca.sh's Docker install pulls -- is +# still the 0.0.x Python edition (FastAPI + Jinja2, another context). Found on +# a real host (1.3.0 validation): there the selection is written and accepted, +# the page cannot render, and Rebecca silently serves its own page instead -- +# an install that reports success and changes nothing a subscriber sees. So +# the edition is established first, and anything but 1.x is refused. + +rt_panel_rebecca_image() { + # echo the Rebecca image the compose file runs, or nothing. + [ -f "$RT_RB_APP_DIR/docker-compose.yml" ] || return 0 + LC_ALL=C awk ' + { l=$0; sub(/\r$/,"",l) } + l ~ /^[ \t]*image:/ { + v=l; sub(/^[ \t]*image:[ \t]*/,"",v); gsub(/["\047]/,"",v); sub(/[ \t].*$/,"",v) + if (v ~ /(^|\/)rebeccapanel\/rebecca([:@]|$)/) { print v; exit } + }' "$RT_RB_APP_DIR/docker-compose.yml" 2>/dev/null || true +} + +rt_panel_rebecca_edition() { + # go | python | unknown. A binary install is 1.x by definition (the Python + # edition has none); a Docker one is told apart by its image's entrypoint. + local img cfg + case "$(rt_panel_rebecca_mode)" in + binary) printf 'go'; return 0 ;; + docker) : ;; + *) printf 'unknown'; return 0 ;; + esac + command -v docker >/dev/null 2>&1 || { printf 'unknown'; return 0; } + img="$(rt_panel_rebecca_image)" + [ -n "$img" ] || { printf 'unknown'; return 0; } + cfg="$(docker image inspect -f '{{json .Config.Entrypoint}} {{json .Config.Cmd}} {{.Config.WorkingDir}}' "$img" 2>/dev/null || true)" + case "$cfg" in + *rebecca-server*) printf 'go' ;; + *'/code'*) printf 'python' ;; + *) printf 'unknown' ;; + esac +} + +rt_panel_rebecca_edition_ok() { + # 0 when this Rebecca can serve the page this release builds for it; + # otherwise say why and fail. Unknown fails closed. + case "$(rt_panel_rebecca_edition)" in + go) return 0 ;; + python) + rt_err "panel rebecca: this is Rebecca 0.0.x, the Python edition (Docker image $(rt_panel_rebecca_image)). Row-Template's Rebecca page is built for Rebecca 1.x, the Go edition, which Rebecca publishes for its binary install (rebecca-binary.sh); Rebecca's own 'rebecca migrate-binary' moves a Docker install to it." ;; + *) + rt_err "panel rebecca: cannot tell which Rebecca edition this is (the Docker image could not be inspected); refusing rather than placing a page Rebecca may not be able to render." ;; + esac + return 1 +} + +rt_panel_rebecca_db() { + # Echo the host path of Rebecca's SQLite database, or fail. Reads ONE key of + # .env and never prints it: a MySQL URL carries a password. + local env url path rc=0 + env="$(rt_panel_rebecca_env)" + [ -f "$env" ] && [ ! -L "$env" ] || return 1 + url="$(rt_dotenv_get "$env" SQLALCHEMY_DATABASE_URL)" || rc=$? + if [ "$rc" -ne 0 ] || [ -z "$url" ]; then + rc=0; url="$(rt_dotenv_get "$env" DATABASE_URL)" || rc=$? + [ "$rc" -eq 0 ] && [ -n "$url" ] || return 1 + fi + case "$url" in + sqlite:///*|sqlite+*:///*) path="${url#*:///}" ;; + *) return 1 ;; # MySQL/MariaDB: not ours to touch + esac + path="${path%%\?*}" + case "$path" in + /*) : ;; + *) [ "$(rt_panel_rebecca_mode)" = "binary" ] || return 1 # relative: inside the image, not the host + path="$RT_RB_APP_DIR/$path" ;; + esac + if [ "$(rt_panel_rebecca_mode)" = "docker" ]; then + rt_is_within "$RT_RB_DATA_DIR" "$path" || return 1 + fi + rt_is_sqlite_db "$path" || return 1 + printf '%s' "$path" +} + +rt_panel_rebecca_db_ready() { + command -v sqlite3 >/dev/null 2>&1 || return 1 + RT_RB_DB="$(rt_panel_rebecca_db)" || return 1 + [ -n "$RT_RB_DB" ] +} + +rt_panel_rebecca_sql() { + # One statement against Rebecca's database, waiting for a lock rather than + # failing on it: the panel keeps the database open while it runs. Values in + # the statement are escaped by rt_panel_rebecca_quote; output is data. + sqlite3 -cmd '.timeout 5000' "$RT_RB_DB" "$1" +} + +rt_panel_rebecca_quote() { printf '%s' "${1//\'/\'\'}"; } + +RT_RB_ROW="(SELECT id FROM subscription_settings ORDER BY id DESC LIMIT 1)" + +rt_panel_rebecca_page_get() { + # Echo subscription_page_template of the row Rebecca reads. Fails when there + # is no row, or the value holds a newline (it could not be restored exactly). + local n v + n="$(rt_panel_rebecca_sql "SELECT COUNT(*) FROM subscription_settings;")" || return 1 + case "${n:-}" in ''|*[!0-9]*|0) return 1 ;; esac + v="$(rt_panel_rebecca_sql "SELECT subscription_page_template FROM subscription_settings WHERE id = $RT_RB_ROW;")" || return 1 + case "$v" in *' +'*) return 1 ;; esac + printf '%s' "$v" +} + +rt_panel_rebecca_dir_get() { + # Echo custom_templates_directory as STATE:VALUE, STATE = absent (NULL) | + # empty | present. NULL and '' are different, and a restore needs which. + local v + v="$(rt_panel_rebecca_sql "SELECT CASE WHEN custom_templates_directory IS NULL THEN 'N' ELSE 'V' || custom_templates_directory END FROM subscription_settings WHERE id = $RT_RB_ROW;")" || return 1 + case "$v" in *' +'*) return 1 ;; esac + case "$v" in + N) printf 'absent:' ;; + V) printf 'empty:' ;; + V*) printf 'present:%s' "${v#V}" ;; + *) return 1 ;; + esac +} + +rt_panel_rebecca_write() { + # rt_panel_rebecca_write PAGE DIR_STATE [DIR] -- set both columns of the row + # Rebecca reads, and nothing else. + local page dstate="$2" dir="${3:-}" dsql + page="$(rt_panel_rebecca_quote "$1")" + case "$dstate" in + absent) dsql="NULL" ;; + empty) dsql="''" ;; + present) dsql="'$(rt_panel_rebecca_quote "$dir")'" ;; + keep) dsql="custom_templates_directory" ;; + *) return 1 ;; + esac + rt_panel_rebecca_sql "UPDATE subscription_settings SET subscription_page_template = '$page', custom_templates_directory = $dsql WHERE id = $RT_RB_ROW;" +} + +rt_panel_rebecca_dir_ok() { + # An operator directory we are willing to place a file into: absolute, no + # control characters, no . or .. component. + case "${1:-}" in /*) : ;; *) return 1 ;; esac + rt_has_control_chars "$1" && return 1 + case "$1" in */../*|*/..|*/./*|*/.) return 1 ;; esac + return 0 +} + +rt_panel_rebecca_root() { + # The directory the page goes into: the operator's custom directory, else + # DATA_DIR/templates. In Docker it must be inside the bind-mounted DATA_DIR. + local d root + d="$(rt_panel_rebecca_dir_get)" || return 1 + root="${d#*:}"; root="${root%/}" + [ -n "$root" ] || root="${RT_RB_DATA_DIR%/}/templates" + rt_panel_rebecca_dir_ok "$root" || { rt_err "panel rebecca: custom_templates_directory is not a usable absolute path"; return 1; } + if [ "$(rt_panel_rebecca_mode)" = "docker" ] && ! rt_is_within "$RT_RB_DATA_DIR" "$root"; then + rt_err "panel rebecca: $root is outside $RT_RB_DATA_DIR, the directory the container shares with the host" + return 1 + fi + printf '%s' "$root" +} + +rt_panel_rebecca_is_ours() { + local f="$1" + [ -f "$f" ] && [ ! -L "$f" ] || return 1 + LC_ALL=C grep -Fq '/* row:branding */' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq 'Row-Template, Rebecca page context' "$f" 2>/dev/null || return 1 + return 0 +} + +rt_panel_rebecca_shell_ok() { + # A Rebecca shell this release can serve: valid, with the context prelude and + # the explicit autoescape block. Anything older is refused. + local f="$1" + rt_validate_template "$f" >/dev/null 2>&1 || return 1 + LC_ALL=C grep -Fq 'Row-Template, Rebecca page context' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq '{%- autoescape on -%}' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq '{%- endautoescape %}' "$f" 2>/dev/null || return 1 + return 0 +} + +rt_panel_rebecca_admin_overrides() { + # Echo how many admins override the page for their own users (0 when none or + # unknown). A warning, never a failure: those users are the admin's choice. + local n + n="$(rt_panel_rebecca_sql "SELECT COUNT(*) FROM admins WHERE subscription_settings LIKE '%\"subscription_page_template\":\"_%' OR subscription_settings LIKE '%\"subscription_page_template\": \"_%' OR subscription_settings LIKE '%\"custom_templates_directory\":\"_%' OR subscription_settings LIKE '%\"custom_templates_directory\": \"_%';" 2>/dev/null)" || n=0 + case "${n:-}" in ''|*[!0-9]*) n=0 ;; esac + printf '%s' "$n" +} + +# --- the frozen verbs -------------------------------------------------------------- + +rt_panel_rebecca_detect() { + # READ-ONLY. Two independent signals must agree: + # A /opt/rebecca/.env + # B a compose file running rebeccapanel/rebecca, or a rebecca systemd unit + # C the rebecca management CLI + # D the data directory + local signals=0 env + env="$(rt_panel_rebecca_env)" + [ -f "$env" ] && [ ! -L "$env" ] && signals=$((signals + 1)) + if rt_panel_rebecca_compose_ok || rt_panel_rebecca_unit_ok; then signals=$((signals + 1)); fi + if [ -x "$RT_RB_CLI" ] && [ ! -d "$RT_RB_CLI" ] && LC_ALL=C grep -qi 'rebecca' "$RT_RB_CLI" 2>/dev/null; then + signals=$((signals + 1)) + fi + [ -d "$RT_RB_DATA_DIR" ] && [ ! -L "$RT_RB_DATA_DIR" ] && signals=$((signals + 1)) + if [ "$signals" -ge 2 ]; then return "$RT_PANEL_OK"; fi + if [ "$signals" -eq 1 ]; then return "$RT_PANEL_FAIL"; fi + return "$RT_PANEL_NOT_APPLICABLE" +} + +rt_panel_rebecca_capabilities() { + local t + for t in $RT_PANEL_REBECCA_CAPABILITIES; do printf '%s\n' "$t"; done + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_backup_state() { + # selection subscription_page_template (present, or empty) + # meta mechanism=db, was_running (recorded; Rebecca is never restarted) + # files the page, when this change will create it + # aux dir_state/dir: custom_templates_directory exactly (NULL, '' or + # a value), root, root_created + local panel="$1" page state was_running=0 d root files=() + rt_panel_rebecca_edition_ok || return "$RT_PANEL_FAIL" + rt_panel_rebecca_db_ready || return "$RT_PANEL_UNAVAILABLE" + page="$(rt_panel_rebecca_page_get)" || { rt_err "panel rebecca: cannot read subscription_settings"; return "$RT_PANEL_FAIL"; } + if [ -z "$page" ]; then state="empty"; else state="present"; fi + d="$(rt_panel_rebecca_dir_get)" || { rt_err "panel rebecca: cannot read custom_templates_directory"; return "$RT_PANEL_FAIL"; } + root="$(rt_panel_rebecca_root)" || return "$RT_PANEL_FAIL" + rt_panel_rebecca_running && was_running=1 + [ -e "$root/$RT_RB_PAGE" ] || [ -L "$root/$RT_RB_PAGE" ] || files+=("$RT_RB_PAGE") + rt_backup_panel_write "$RT_PANEL_STAGE" "$panel" "$state" "$page" db "$was_running" ${files[@]+"${files[@]}"} \ + || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" dir_state "${d%%:*}" || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" dir "${d#*:}" || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" root "$root" || return "$RT_PANEL_FAIL" + if [ ! -d "$root" ]; then + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" root_created 1 || return "$RT_PANEL_FAIL" + fi + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_place() { + local src="$1" root="$2" dir dest tmp + dir="$root/$RT_RB_SUBDIR"; dest="$root/$RT_RB_PAGE" + [ -L "$root" ] && { rt_err "panel rebecca: the templates directory is a symlink: $root"; return 1; } + [ -L "$dir" ] && { rt_err "panel rebecca: $dir is a symlink"; return 1; } + if [ -e "$dest" ] || [ -L "$dest" ]; then + rt_panel_rebecca_is_ours "$dest" \ + || { rt_err "panel rebecca: $dest exists and is not Row-Template's; it was left untouched"; return 1; } + fi + mkdir -p "$dir" || return 1 + chmod 755 "$dir" 2>/dev/null || true + tmp="$(mktemp "$dir/.index.XXXXXX")" || return 1 + cp -- "$src" "$tmp" && chmod 644 "$tmp" && mv -f "$tmp" "$dest" || { rm -f "$tmp"; return 1; } + return 0 +} + +rt_panel_rebecca_install_template() { + # Place SOURCE and select it. Idempotent: when already selected, only the + # page is replaced. + local panel="$1" src="$2" root d page + [ -n "$src" ] || { rt_err "panel rebecca: SOURCE is required"; return "$RT_PANEL_FAIL"; } + [ -L "$src" ] && { rt_err "panel rebecca: refusing a symlinked SOURCE"; return "$RT_PANEL_FAIL"; } + [ -f "$src" ] || { rt_err "panel rebecca: SOURCE is not a regular file: $src"; return "$RT_PANEL_FAIL"; } + rt_is_within "$RT_ROOT" "$src" || { rt_err "panel rebecca: SOURCE is outside $RT_ROOT"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_shell_ok "$src" || { rt_err "panel rebecca: SOURCE is not a Rebecca page this release can serve"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_edition_ok || return "$RT_PANEL_FAIL" + rt_panel_rebecca_db_ready || return "$RT_PANEL_UNAVAILABLE" + root="$(rt_panel_rebecca_root)" || return "$RT_PANEL_FAIL" + rt_panel_rebecca_place "$src" "$root" || return "$RT_PANEL_FAIL" + page="$(rt_panel_rebecca_page_get)" || return "$RT_PANEL_FAIL" + d="$(rt_panel_rebecca_dir_get)" || return "$RT_PANEL_FAIL" + if [ "$page" = "$RT_RB_PAGE" ] && [ "${d#*:}" != "" ]; then + return "$RT_PANEL_OK" + fi + if [ -n "${d#*:}" ]; then + rt_panel_rebecca_write "$RT_RB_PAGE" keep || return "$RT_PANEL_FAIL" + else + rt_panel_rebecca_write "$RT_RB_PAGE" present "$root" || return "$RT_PANEL_FAIL" + fi + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_selected() { + local root page d + page="$(rt_panel_rebecca_page_get 2>/dev/null)" || return 1 + [ "$page" = "$RT_RB_PAGE" ] || return 1 + d="$(rt_panel_rebecca_dir_get 2>/dev/null)" || return 1 + root="$(rt_panel_rebecca_root 2>/dev/null)" || return 1 + [ "$(printf '%s' "${d#*:}" | sed 's:/*$::')" = "$root" ] +} + +rt_panel_rebecca_verify() { + local panel="$1" mode="$2" root dest want n + case "$mode" in + live) return "$RT_PANEL_UNAVAILABLE" ;; + static) : ;; + *) rt_err "panel rebecca: unknown verification mode '$mode'"; return "$RT_PANEL_FAIL" ;; + esac + [ -f "$RT_DIST" ] || { rt_err "panel rebecca: the artifact is missing: $RT_DIST"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_shell_ok "$RT_DIST" || { rt_err "panel rebecca: the artifact is not a valid Rebecca page"; return "$RT_PANEL_FAIL"; } + if [ -f "$RT_DIST_SUM" ]; then + want="$(LC_ALL=C awk '{print $1; exit}' "$RT_DIST_SUM" 2>/dev/null || true)" + rt_verify_sha256 "$RT_DIST" "$want" >/dev/null 2>&1 \ + || { rt_err "panel rebecca: the artifact does not match its recorded checksum"; return "$RT_PANEL_FAIL"; } + fi + rt_validate_template "$RT_LIVE" >/dev/null 2>&1 \ + || { rt_err "panel rebecca: the generated page is missing or invalid: $RT_LIVE"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_edition_ok || return "$RT_PANEL_FAIL" + rt_panel_rebecca_db_ready || { rt_err "panel rebecca: cannot read the panel selection (sqlite3 and a SQLite database are needed)"; return "$RT_PANEL_FAIL"; } + root="$(rt_panel_rebecca_root)" || return "$RT_PANEL_FAIL" + dest="$root/$RT_RB_PAGE" + rt_panel_rebecca_is_ours "$dest" || { rt_err "panel rebecca: the page is not in place: $dest"; return "$RT_PANEL_FAIL"; } + [ "$(rt_sha256 "$dest" 2>/dev/null || true)" = "$(rt_sha256 "$RT_LIVE" 2>/dev/null || true)" ] \ + || { rt_err "panel rebecca: the placed page differs from the generated one"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_selected || { rt_err "panel rebecca: the panel does not select the Row-Template page"; return "$RT_PANEL_FAIL"; } + n="$(rt_panel_rebecca_admin_overrides)" + [ "$n" = "0" ] || rt_warn "panel rebecca: $n admin(s) override the subscription page for their own users; those users keep the admin's page." + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_remove_page() { + local root="$1" root_created="${2:-0}" dest + dest="$root/$RT_RB_PAGE" + if [ -e "$dest" ] || [ -L "$dest" ]; then + rt_panel_rebecca_is_ours "$dest" || { rt_warn "panel rebecca: $dest is not Row-Template's; left in place"; return 0; } + rm -f -- "$dest" || return 1 + fi + rmdir -- "$root/$RT_RB_SUBDIR" 2>/dev/null || true + if [ "$root_created" = "1" ]; then rmdir -- "$root" 2>/dev/null || true; fi + return 0 +} + +rt_panel_rebecca_restore_record() { + # rt_panel_rebecca_restore_record SNAPSHOT PANEL -- put both columns back + # exactly as the record has them. Validates everything before writing, then + # reads both columns BACK and compares them with the record. + # + # The read-back is what makes this function's SUCCESS mean what interface.sh + # says SUCCESS means -- "operation completed AND its required verification + # passed" -- and the transaction engine's rollback now relies on exactly that + # rather than re-running a forward check of its own. A correct rollback + # deliberately stops the panel selecting Row-Template, so a forward check + # would fail by design; the comparison with the record is the real evidence. + local snap="$1" panel="$2" st page dstate dir nowpage nowdir + st="$(rt_backup_panel_state "$snap" "$panel")" || return 1 + case "$st" in + present) page="$(rt_backup_panel_selection "$snap" "$panel")" || return 1 ;; + empty) page="" ;; + *) rt_err "panel rebecca: subscription_page_template cannot have been absent (state '$st')"; return 1 ;; + esac + dstate="$(rt_backup_panel_aux "$snap" "$panel" dir_state)" || return 1 + dir="$(rt_backup_panel_aux "$snap" "$panel" dir)" || return 1 + case "$dstate" in absent|empty|present) : ;; *) rt_err "panel rebecca: malformed dir_state record"; return 1 ;; esac + rt_panel_rebecca_write "$page" "$dstate" "$dir" || return 1 + + nowpage="$(rt_panel_rebecca_page_get)" \ + || { rt_err "panel rebecca: cannot read subscription_page_template back after restoring"; return 1; } + [ "$nowpage" = "$page" ] || { + rt_err "panel rebecca: after restoring, subscription_page_template is '$nowpage', expected '$page'" + return 1; } + nowdir="$(rt_panel_rebecca_dir_get)" \ + || { rt_err "panel rebecca: cannot read custom_templates_directory back after restoring"; return 1; } + [ "$nowdir" = "$dstate:$dir" ] || { + rt_err "panel rebecca: after restoring, custom_templates_directory is not the recorded value" + return 1; } + return 0 +} + +rt_panel_rebecca_restore_state() { + local panel="$1" snap="$2" mech files f root created + rt_backup_panel_state "$snap" "$panel" >/dev/null || { rt_err "panel rebecca: malformed selection.state"; return "$RT_PANEL_FAIL"; } + rt_backup_panel_meta_check "$snap" "$panel" || { rt_err "panel rebecca: malformed panel meta"; return "$RT_PANEL_FAIL"; } + mech="$(rt_manifest_get mechanism "$snap/panels/$panel/meta")" + [ "$mech" = "db" ] || { rt_err "panel rebecca: mechanism is '$mech', expected 'db'"; return "$RT_PANEL_FAIL"; } + files="$(rt_backup_panel_files "$snap" "$panel")" || { rt_err "panel rebecca: malformed files record"; return "$RT_PANEL_FAIL"; } + for f in $files; do + [ "$f" = "$RT_RB_PAGE" ] || { rt_err "panel rebecca: refusing to restore: the record lists a file this adapter never places: $f"; return "$RT_PANEL_FAIL"; } + done + root="$(rt_backup_panel_aux "$snap" "$panel" root)" || return "$RT_PANEL_FAIL" + created="$(rt_backup_panel_aux "$snap" "$panel" root_created)" || return "$RT_PANEL_FAIL" + rt_panel_rebecca_dir_ok "$root" || { rt_err "panel rebecca: malformed root record"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_db_ready || return "$RT_PANEL_UNAVAILABLE" + rt_panel_rebecca_restore_record "$snap" "$panel" || return "$RT_PANEL_FAIL" + if [ -n "$files" ]; then + rt_panel_rebecca_remove_page "$root" "${created:-0}" || return "$RT_PANEL_FAIL" + fi + # was_running is recorded but never acted on: this adapter never stops or + # starts Rebecca, so the service is exactly as the record found it. + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_uninstall_template() { + # Put the selection back the way it was before Row-Template (from the + # activation record when there is one), then remove our page. When the panel + # no longer selects our page, the selection is the operator's and is left + # alone; our page is still removed. + local page d root snap created=0 removed=0 + rt_panel_rebecca_db_ready || return "$RT_PANEL_UNAVAILABLE" + page="$(rt_panel_rebecca_page_get)" || return "$RT_PANEL_FAIL" + d="$(rt_panel_rebecca_dir_get)" || return "$RT_PANEL_FAIL" + root="$(rt_panel_rebecca_root 2>/dev/null)" || root="" + if [ "$page" = "$RT_RB_PAGE" ]; then + snap="" + if [ -f "${RT_PANEL_ACTIVATION:-/nonexistent}" ]; then + snap="$(rt_backup_resolve "$(head -n1 "$RT_PANEL_ACTIVATION")" 2>/dev/null || true)" + fi + if [ -n "$snap" ] && [ -d "$snap/panels/rebecca" ] && rt_panel_rebecca_restore_record "$snap" rebecca; then + created="$(rt_backup_panel_aux "$snap" rebecca root_created 2>/dev/null || echo 0)" + else + # No usable record: return to Rebecca's own default page, and clear the + # directory only when it is the one Row-Template itself sets. + if [ "${d#*:}" = "${RT_RB_DATA_DIR%/}/templates" ]; then + rt_panel_rebecca_write "$RT_RB_DEFAULT_PAGE" absent || return "$RT_PANEL_FAIL" + created=1 + else + rt_panel_rebecca_write "$RT_RB_DEFAULT_PAGE" keep || return "$RT_PANEL_FAIL" + fi + fi + removed=1 + fi + if [ -n "$root" ] && rt_panel_rebecca_is_ours "$root/$RT_RB_PAGE"; then + rt_panel_rebecca_remove_page "$root" "$created" || return "$RT_PANEL_FAIL" + removed=1 + fi + [ "$removed" -eq 1 ] || return "$RT_PANEL_NOT_APPLICABLE" + return "$RT_PANEL_OK" +} + +# --- outside the transaction: refresh and status ---------------------------- +# See installer/panels/pasarguard.sh. Without database access (MySQL/MariaDB, +# or no sqlite3) the page still goes to the default directory, so the +# operator's manual selection in the dashboard has a file to point at. + +rt_panel_rebecca_page_root() { + # The directory the page lives in: from the database when it can be read, + # else the default the manual instructions name. + if rt_panel_rebecca_db_ready; then rt_panel_rebecca_root; return; fi + printf '%s' "${RT_RB_DATA_DIR%/}/templates" +} + +rt_panel_rebecca_refresh() { + # rt_panel_rebecca_refresh SOURCE [place] + local src="$1" place="${2:-}" root + rt_panel_rebecca_shell_ok "$src" || return "$RT_PANEL_FAIL" + # Installing fails closed on an edition it cannot identify; refreshing an + # existing install refuses only one it KNOWS cannot serve the page, so a + # Docker daemon that is briefly unreachable does not block a rebrand. + if [ "$(rt_panel_rebecca_edition)" = python ]; then + rt_panel_rebecca_edition_ok + return "$RT_PANEL_FAIL" + fi + if ! root="$(rt_panel_rebecca_page_root 2>/dev/null)"; then + # A directory Row-Template cannot use holds no page of ours -- unless the + # panel selects our page from it, which is a real failure to report. + if [ -z "$place" ] && [ "$(rt_panel_rebecca_page_get 2>/dev/null || true)" != "$RT_RB_PAGE" ]; then + return "$RT_PANEL_NOT_APPLICABLE" + fi + rt_panel_rebecca_page_root >/dev/null + return "$RT_PANEL_FAIL" + fi + if [ -z "$place" ] && ! rt_panel_rebecca_is_ours "$root/$RT_RB_PAGE"; then + return "$RT_PANEL_NOT_APPLICABLE" + fi + rt_panel_rebecca_place "$src" "$root" || return "$RT_PANEL_FAIL" + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_status() { + # active | inactive | manual (the selection cannot be read or written here) + local root + rt_panel_rebecca_db_ready || { printf 'manual'; return 0; } + root="$(rt_panel_rebecca_root 2>/dev/null)" || { printf 'inactive'; return 0; } + if rt_panel_rebecca_is_ours "$root/$RT_RB_PAGE" && rt_panel_rebecca_selected; then + printf 'active' + else + printf 'inactive' + fi +} diff --git a/src/panels/pasarguard/prelude.jinja2 b/src/panels/pasarguard/prelude.jinja2 new file mode 100644 index 0000000..84597b5 --- /dev/null +++ b/src/panels/pasarguard/prelude.jinja2 @@ -0,0 +1,61 @@ +{#- ========================================================================== + Row-Template -> PasarGuard page context. + + PasarGuard renders its subscription page with Jinja2 and hands the template + { user, links, announce, announce_url, apps }. The Row layouts read 3X-UI's + names instead (enabled, isOnline, downloadByte, expire, ...). This prelude + derives every one of those names from PasarGuard's own context, once, at the + top of the page, so the layout below it is the same document on every panel. + + Rules (docs/design/PASARGUARD-ADAPTER-AUDIT.md, tools/adapters/pasarguard.mjs): + enabled status != disabled; on_hold, limited and expired are enabled + (the page derives "expired"/"limited" from the numbers) + isOnline online_at within 120 s of now() -- an adapter window, not a + panel fact; never inferred from traffic + downloadByte used_traffic, the panel's single combined counter + uploadByte 0, exactly as PasarGuard's own subscription-userinfo header + totalByte data_limit, None/0 = unlimited = 0 + expire epoch seconds; 0 = never; on_hold with a duration becomes the + negative duration (the page's "starts on first connection"); + on_hold without one is outside the plausible range, which the + page reads as unknown + 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 + support the admin's support_url when the panel supplies one + The subscriber's address (user.ip) is never read. + + Values are escaped on output by the autoescape block that follows this + prelude: PasarGuard's environment does NOT autoescape by itself. +========================================================================== -#} +{%- set rt_status = user.status.value if user.status.value is defined else (user.status ~ '') -%} +{%- set enabled = rt_status != 'disabled' -%} +{%- set rt_seen = user.online_at if user.online_at else none -%} +{%- set rt_age = (now() - rt_seen).total_seconds() if rt_seen else -1 -%} +{%- set isOnline = (rt_seen is not none) and rt_age >= 0 and rt_age <= 120 -%} +{%- set lastOnline = ((rt_seen.timestamp() * 1000) | int) if rt_seen else '' -%} +{%- set downloadByte = (user.used_traffic or 0) | int -%} +{%- set uploadByte = 0 -%} +{%- set totalByte = (user.data_limit or 0) | int -%} +{%- if rt_status == 'on_hold' and user.on_hold_expire_duration -%} +{%- set expire = 0 - (user.on_hold_expire_duration | int) -%} +{%- elif rt_status == 'on_hold' -%} +{%- set expire = 9999999999 -%} +{%- elif user.expire is number -%} +{%- set expire = user.expire | int -%} +{%- elif user.expire -%} +{%- set expire = user.expire.timestamp() | int -%} +{%- else -%} +{%- set expire = 0 -%} +{%- 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 datepicker = 'gregorian' -%} +{%- set announce = announce or '' -%} +{%- set links = links or [] -%} +{%- set used = downloadByte | bytesformat -%} +{%- set total = totalByte | bytesformat -%} +{%- set remained = ((totalByte - downloadByte) | bytesformat) if totalByte > downloadByte else '' -%} diff --git a/src/panels/rebecca/prelude.pongo2 b/src/panels/rebecca/prelude.pongo2 new file mode 100644 index 0000000..5535688 --- /dev/null +++ b/src/panels/rebecca/prelude.pongo2 @@ -0,0 +1,84 @@ +{%- comment -%} + Row-Template, Rebecca page context. + + Rebecca renders its subscription page with pongo2 and hands the template a + map: user (username, status, data_limit, used_traffic, expire, online_at, + subscription_url, ...), links, support_url and current_timestamp. The Row + layouts read the 3X-UI names instead (enabled, isOnline, downloadByte, + expire, ...). This prelude derives every one of them from Rebecca's own + context, once, so the layout below it is the same document on every panel. + + Rules (docs/design/REBECCA-ADAPTER-DECISIONS.md, tools/adapters/rebecca.mjs): + enabled status is not disabled; active, limited, expired and on_hold + are enabled (the page derives expired and limited itself). A + status Rebecca does not know is classed disabled by Rebecca + itself (status_class), and so it is here: fail safe + isOnline online_at within 120 s of current_timestamp while enabled, an + adapter window and not a panel fact; never inferred from traffic + downloadByte used_traffic, the panel's single combined counter + uploadByte 0, exactly as Rebecca's own subscription-userinfo header + totalByte data_limit, which Rebecca passes only when positive + expire epoch seconds, already; absent means never (0); on_hold is + outside the plausible range, which the page reads as unknown, + because Rebecca does not pass the hold duration to templates + lastOnline online_at in epoch milliseconds. Rebecca writes it WITHOUT a + zone and it is UTC; pongo2 cannot parse dates, so the civil + date is converted with integer arithmetic (days from civil). + A value with a non-UTC offset is left unknown, never guessed. + subUrl user.subscription_url, and Clash Meta at its /clash-meta suffix + support support_url + Rebecca has no announcement and no page title in its template context. + + Every value is escaped on output: pongo2 autoescapes by default, and the + explicit autoescape block after this prelude keeps it so. +{%- endcomment -%} +{%- set rt_status = user.status -%} +{%- set enabled = true -%} +{%- if rt_status == "disabled" or user.status_class == "disabled" -%}{%- set enabled = false -%}{%- endif -%} +{%- set downloadByte = user.used_traffic|integer -%} +{%- set uploadByte = 0 -%} +{%- set totalByte = 0 -%} +{%- if user.data_limit -%}{%- set totalByte = user.data_limit|integer -%}{%- endif -%} +{%- set expire = 0 -%} +{%- if rt_status == "on_hold" -%}{%- set expire = 9999999999 -%}{%- elif user.expire -%}{%- set expire = user.expire|integer -%}{%- endif -%} +{%- set isOnline = false -%} +{%- set lastOnline = "" -%} +{%- if user.online_at -%} +{%- set rt_at = user.online_at -%} +{%- set rt_tail = rt_at|slice:"19:" -%} +{%- set rt_zone_ok = false -%} +{%- if rt_tail == "" or rt_tail == "Z" or rt_tail == "+00:00" -%}{%- set rt_zone_ok = true -%}{%- endif -%} +{%- if rt_tail|slice:"0:1" == "." and not ("+" in rt_tail) and not ("-" in rt_tail) -%}{%- set rt_zone_ok = true -%}{%- endif -%} +{%- if rt_at|length >= 19 and rt_zone_ok and rt_at|slice:"4:5" == "-" and rt_at|slice:"7:8" == "-" and rt_at|slice:"13:14" == ":" and rt_at|slice:"16:17" == ":" -%} +{%- set rt_y = rt_at|slice:"0:4"|integer -%} +{%- set rt_m = rt_at|slice:"5:7"|integer -%} +{%- set rt_d = rt_at|slice:"8:10"|integer -%} +{%- set rt_hh = rt_at|slice:"11:13"|integer -%} +{%- set rt_mi = rt_at|slice:"14:16"|integer -%} +{%- set rt_ss = rt_at|slice:"17:19"|integer -%} +{%- set rt_yy = rt_y -%} +{%- if rt_m <= 2 -%}{%- set rt_yy = rt_y - 1 -%}{%- set rt_mp = rt_m + 9 -%}{%- else -%}{%- set rt_mp = rt_m - 3 -%}{%- endif -%} +{%- set rt_era = rt_yy / 400 -%} +{%- set rt_yoe = rt_yy - rt_era * 400 -%} +{%- set rt_doy = (153 * rt_mp + 2) / 5 + rt_d - 1 -%} +{%- set rt_doe = rt_yoe * 365 + rt_yoe / 4 - rt_yoe / 100 + rt_doy -%} +{%- set rt_secs = (rt_era * 146097 + rt_doe - 719468) * 86400 + rt_hh * 3600 + rt_mi * 60 + rt_ss -%} +{%- if rt_y >= 1970 and rt_m >= 1 and rt_m <= 12 and rt_d >= 1 and rt_d <= 31 -%} +{%- set lastOnline = rt_secs * 1000 -%} +{%- set rt_age = current_timestamp - rt_secs -%} +{%- if enabled and rt_age >= 0 and rt_age <= 120 -%}{%- set isOnline = true -%}{%- endif -%} +{%- endif -%} +{%- endif -%} +{%- endif -%} +{%- set subUrl = user.subscription_url -%} +{%- set subJsonUrl = "" -%} +{%- set subClashUrl = "" -%} +{%- if subUrl -%}{%- set subClashUrl = subUrl|add:"/clash-meta" -%}{%- endif -%} +{%- set subTitle = "" -%} +{%- set subSupportUrl = support_url -%} +{%- set datepicker = "gregorian" -%} +{%- set announce = "" -%} +{%- set used = downloadByte|bytesformat -%} +{%- set total = totalByte|bytesformat -%} +{%- set remained = "" -%} +{%- if totalByte > downloadByte -%}{%- set rt_left = totalByte - downloadByte -%}{%- set remained = rt_left|bytesformat -%}{%- endif -%} diff --git a/src/templates/meter/base.css b/src/templates/meter/base.css new file mode 100644 index 0000000..21f26f9 --- /dev/null +++ b/src/templates/meter/base.css @@ -0,0 +1,127 @@ +/* Reset, document shell and the primitives everything else builds on. */ + +*, +*::before, +*::after { box-sizing: border-box; } + +html { + background: var(--bg); + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font); + font-size: var(--fs-body); + line-height: var(--lh-body); + color: var(--text); + background: var(--bg); + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, p, ul, ol { + margin: 0; + font-weight: inherit; + font-size: inherit; +} + +ul, ol { padding: 0; list-style: none; } + +a { color: var(--accent); } + +button { + margin: 0; + font: inherit; + color: inherit; + font-variant-numeric: inherit; +} + +input, textarea { + font: inherit; + color: inherit; +} + +canvas { display: block; } + +svg.sprite { display: none; } + +.icon { + inline-size: 1.0625rem; + block-size: 1.0625rem; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.announce-text, +#connect-hint { max-inline-size: var(--measure); } + +/* Latin figures stay Latin inside an RTL paragraph. */ +.ltr { + direction: ltr; + unicode-bidi: isolate; +} + +.visually-hidden { + position: absolute; + inline-size: 1px; + block-size: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* Scripting gate: the boot script sets data-js before first paint. */ +html:not([data-js]) .js-only { display: none !important; } + +/* The tap floor, stated on element types so no control can slip under it: + every button, link-button, tab and field is at least 44px tall. */ +button, +a.btn, +[role="tab"], +input { min-block-size: var(--tap); } + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +::selection { + background: var(--accent-strong); + color: var(--on-accent); +} + +@keyframes page-in { + from { + opacity: 0; + transform: translateY(6px); + } +} + +.topbar, +#main { animation: page-in var(--dur-slow) var(--ease); } + +#main { + animation-delay: 60ms; + animation-fill-mode: backwards; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/src/templates/meter/components.css b/src/templates/meter/components.css new file mode 100644 index 0000000..c4b76a8 --- /dev/null +++ b/src/templates/meter/components.css @@ -0,0 +1,892 @@ +/* The component vocabulary. */ + +[hidden] { display: none !important; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-card); +} + +.card-title { + font-size: var(--fs-title); + font-weight: 800; + line-height: var(--lh-tight); +} + +.card-sub { + font-size: var(--fs-caption); + color: var(--muted); +} + +.card-sub:empty { display: none; } + +/* --- brand ------------------------------------------------------------- */ + +.brand { + display: flex; + align-items: center; + gap: var(--sp-3); + min-inline-size: 0; +} + +.brand-mark { + flex: none; + display: grid; + place-items: center; + inline-size: var(--mark); + block-size: var(--mark); + border-radius: var(--r-control); + background: var(--accent-soft); + color: var(--accent); + font-size: 0.9375rem; + font-weight: 800; + overflow: hidden; +} + +.brand-mark img { + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +.brand-name { + min-inline-size: 0; + font-size: clamp(1.375rem, 5vw, 1.875rem); + font-weight: 900; + line-height: 1.25; + letter-spacing: -0.01em; + overflow-wrap: anywhere; +} + +.plan-label { + display: inline-flex; + padding: 2px var(--sp-3); + border-radius: var(--r-pill); + background: var(--accent-soft); + color: var(--accent); + font-size: var(--fs-micro); + font-weight: 700; +} + +/* --- chips --------------------------------------------------------------- */ + +.chips { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 7px; + min-block-size: 30px; + padding: 4px var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--r-pill); + background: var(--surface); + color: var(--muted); + font-size: var(--fs-micro); + font-weight: 600; +} + +.chip:empty { display: none; } + +.dot { + flex: none; + inline-size: 7px; + block-size: 7px; + border-radius: 50%; + background: var(--subtle); +} + +#state-pill[data-state="active"] .dot { + background: var(--success); + box-shadow: 0 0 8px var(--success); + animation: chip-pulse 2s ease-in-out infinite; +} + +#state-pill[data-state="limited"] .dot { background: var(--warning); } + +#state-pill[data-state="expired"] .dot, +#state-pill[data-state="disabled"] .dot { background: var(--danger); } + +.live-state[data-online="1"] .dot { background: var(--success); } + +@keyframes chip-pulse { + 50% { opacity: 0.35; } +} + +/* --- the hero ------------------------------------------------------------ */ + +.hero { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-6) var(--sp-5) var(--sp-5); + min-block-size: 300px; +} + +.hero-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-3); +} + +.hero-label { + font-size: var(--fs-caption); + font-weight: 600; + color: var(--muted); +} + +/* The remaining allowance is the headline: "61.6 GB remaining", "Unlimited", + "No traffic left". It is one sentence from the runtime, set large. */ +.hero-big { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + padding-block: var(--sp-3) var(--sp-1); + font-size: var(--fs-hero); + font-weight: 900; + line-height: var(--lh-hero); + letter-spacing: -0.02em; + text-align: center; + overflow-wrap: anywhere; +} + +.hero-big:empty { display: none; } + +.hero-pill-row { + display: flex; + justify-content: center; +} + +.hero-pill { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + justify-content: center; + gap: 6px; + padding: 6px var(--sp-4); + border: 1px solid var(--border); + border-radius: var(--r-pill); + background: var(--surface-2); + color: var(--muted); + font-size: var(--fs-micro); + font-weight: 600; +} + +.hero-pill .icon { + inline-size: 0.875rem; + block-size: 0.875rem; +} + +.hero-used { + color: var(--text); + font-weight: 800; +} + +/* The meter: one track, cut into equal segments by a mask, so the filled and + the empty segments line up in either direction and at any width. */ +.meter { margin-block-start: var(--sp-3); } + +.meter:empty { display: none; } + +.bar-row { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.bar { + position: relative; + display: block; + block-size: var(--meter-h); + background: var(--meter-off); + border-radius: var(--r-seg); + overflow: hidden; + -webkit-mask: repeating-linear-gradient(90deg, #000 0 var(--seg), transparent var(--seg) calc(var(--seg) + var(--seg-gap))); + mask: repeating-linear-gradient(90deg, #000 0 var(--seg), transparent var(--seg) calc(var(--seg) + var(--seg-gap))); +} + +.bar-fill { + display: block; + block-size: 100%; + background: linear-gradient(180deg, var(--meter-top), var(--meter-bottom)); + box-shadow: 0 0 12px var(--glow); + transform-origin: bottom; + animation: meter-rise var(--dur-slow) var(--ease-spring) backwards; + transition: inline-size var(--dur-slow) var(--ease); +} + +.bar[data-level="warn"] .bar-fill { + background: linear-gradient(180deg, var(--meter-warn-top), var(--meter-warn-bottom)); +} + +.bar[data-level="over"] .bar-fill { + background: linear-gradient(180deg, var(--meter-over-top), var(--meter-over-bottom)); +} + +@keyframes meter-rise { + from { transform: scaleY(0); } +} + +.bar-pct { + align-self: flex-end; + color: var(--muted); + font-size: var(--fs-micro); + font-weight: 700; +} + +.hero-expire { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + margin-block-start: var(--sp-2); + text-align: center; +} + +.expire-value { + font-size: var(--fs-caption); + font-weight: 800; +} + +.expire-value[data-level="warn"] { color: var(--warning); } + +.expire-value[data-level="over"] { color: var(--danger); } + +.expire-caption { + font-size: var(--fs-micro); + color: var(--muted); +} + +.expire-caption:empty { display: none; } + +.stamp { + align-self: center; + font-size: var(--fs-micro); + color: var(--subtle); +} + +.stamp:empty { display: none; } + +.stamp[data-stale] { color: var(--warning); } + +/* --- actions --------------------------------------------------------------- */ + +.action-card { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-4); +} + +.a-icon { + flex: none; + display: grid; + place-items: center; + inline-size: 40px; + block-size: 40px; + border-radius: var(--r-control); + background: var(--accent-soft); + color: var(--accent); +} + +.action-card .btn { flex: 1 1 auto; } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + min-block-size: var(--btn-h); + padding: var(--sp-2) var(--sp-4); + border: 1px solid var(--border); + border-radius: var(--r-control); + background: var(--surface-2); + color: var(--text); + font-size: var(--fs-caption); + font-weight: 700; + line-height: var(--lh-tight); + text-decoration: none; + cursor: pointer; + transition: border-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease), + background-color var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease); +} + +.btn:active { transform: scale(0.985); } + +.btn-primary { + border-color: transparent; + background: var(--accent-strong); + color: var(--on-accent); +} + +.btn-quiet { + border-color: transparent; + background: transparent; + color: var(--muted); +} + +.btn-sm { + min-block-size: var(--tap); + min-inline-size: 64px; + padding-inline: var(--sp-3); + font-size: var(--fs-micro); +} + +.btn:disabled { + opacity: 0.5; + cursor: default; +} + +.btn-swap { + display: grid; + grid-template-areas: "label"; + place-items: center; + min-inline-size: 0; +} + +.btn-swap > span { + grid-area: label; + max-inline-size: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.btn-swap > .swap-done { visibility: hidden; } + +.btn[data-flash] .swap-live { visibility: hidden; } + +.btn[data-flash] .swap-done { visibility: visible; } + +.btn-outline[data-flash], +.btn-quiet[data-flash] { + border-color: var(--success); + color: var(--success); +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-inline-size: var(--tap); + min-block-size: var(--tap); + padding-inline: var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--r-control); + background: var(--surface); + color: var(--muted); + font-size: var(--fs-micro); + font-weight: 700; + cursor: pointer; + transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease); +} + +.icon-btn[aria-expanded="true"] { + color: var(--text); + border-color: var(--border-strong); +} + +.icon-chevron { + inline-size: 0.875rem; + block-size: 0.875rem; +} + +/* --- menus ------------------------------------------------------------------ */ + +.menu-wrap { position: relative; } + +.menu { + position: absolute; + inset-block-start: calc(100% + var(--sp-2)); + inset-inline-end: 0; + z-index: 20; + min-inline-size: 180px; + padding: var(--sp-1); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-control); + box-shadow: var(--shadow); + animation: menu-in var(--dur) var(--ease); +} + +@keyframes menu-in { + from { + opacity: 0; + transform: translateY(-4px); + } +} + +.menu-item { + display: flex; + align-items: center; + gap: var(--sp-2); + inline-size: 100%; + min-block-size: var(--tap); + padding-inline: var(--sp-3); + border: 0; + border-radius: 8px; + background: transparent; + font-size: var(--fs-caption); + text-align: start; + cursor: pointer; +} + +.menu-item .icon:last-child { + margin-inline-start: auto; + opacity: 0; +} + +.menu-item[aria-checked="true"] { + color: var(--accent); + font-weight: 700; +} + +.menu-item[aria-checked="true"] .icon:last-child { opacity: 1; } + +/* --- the client area ------------------------------------------------------- */ + +.downloads { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-4); +} + +.tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 4px; + border: 1px solid var(--border); + border-radius: var(--r-control); + background: var(--surface-2); +} + +.tab { + flex: 1 1 auto; + min-block-size: var(--tap); + padding-inline: var(--sp-3); + border: 0; + border-radius: 8px; + background: transparent; + color: var(--muted); + font-size: var(--fs-micro); + font-weight: 700; + cursor: pointer; +} + +.tab[aria-selected="true"] { + background: var(--surface); + color: var(--accent); + box-shadow: 0 1px 0 var(--border); +} + +.clients { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.client { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--r-control); + background: var(--surface-2); + transition: border-color var(--dur) var(--ease); +} + +.client:first-child { border-color: var(--accent); } + +.client-head { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.client-name { + flex: 1 1 auto; + font-size: var(--fs-caption); + font-weight: 800; +} + +.client-tag { + padding: 2px var(--sp-2); + border-radius: var(--r-pill); + background: var(--accent-soft); + color: var(--accent); + font-size: 0.6875rem; + font-weight: 700; +} + +.client-actions { + display: flex; + gap: var(--sp-2); +} + +.client-actions .btn { flex: 1 1 0; } + +/* --- the configuration explorer ------------------------------------------- */ + +.explorer { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-4); +} + +.explorer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); +} + +.cfg-count { + min-inline-size: 26px; + padding: 1px var(--sp-2); + border-radius: var(--r-pill); + background: var(--accent-soft); + color: var(--accent); + font-size: var(--fs-micro); + font-weight: 800; + text-align: center; +} + +.cfg-count:empty { display: none; } + +.cfg-search { + display: flex; + align-items: center; + gap: var(--sp-2); + padding-inline: var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--r-control); + background: var(--surface-2); + color: var(--muted); +} + +.cfg-search:focus-within { border-color: var(--accent); } + +.cfg-search input { + flex: 1 1 auto; + min-inline-size: 0; + block-size: var(--tap); + border: 0; + background: transparent; + outline: none; +} + +.cfg-list { + display: flex; + flex-direction: column; + gap: 7px; +} + +.cfg { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--r-control); + background: var(--surface-2); + transition: border-color var(--dur-fast) var(--ease); +} + +.cfg-flag { + flex: none; + display: grid; + place-items: center; + inline-size: 34px; + block-size: 34px; + border-radius: 9px; + background: var(--accent-soft); + color: var(--accent); + font-size: 18px; + line-height: 1; + overflow: hidden; +} + +.cfg-flag[data-mono] { + font-size: var(--fs-caption); + font-weight: 800; +} + +.cfg-body { + flex: 1 1 auto; + min-inline-size: 0; + display: flex; + flex-direction: column; +} + +.cfg-name { + font-size: var(--fs-caption); + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cfg-proto { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--subtle); + text-transform: uppercase; +} + +.cfg-actions { + flex: none; + display: flex; + gap: 4px; +} + +.cfg-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + min-block-size: var(--tap); + border: 1px dashed var(--border-strong); + border-radius: var(--r-control); + background: transparent; + color: var(--muted); + font-size: var(--fs-caption); + font-weight: 700; + cursor: pointer; +} + +.cfg-toggle[aria-expanded="true"] .icon { transform: rotate(180deg); } + +/* --- announcement and support ------------------------------------------------ */ + +.announce { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-4); + border: 1px solid var(--accent); + border-radius: var(--r-card); + background: var(--accent-soft); +} + +.announce-head { + display: flex; + align-items: center; + gap: var(--sp-2); + color: var(--accent); +} + +.section-title { + font-size: var(--fs-caption); + font-weight: 800; +} + +.announce-text { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + line-clamp: 4; + overflow: hidden; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--fs-caption); +} + +.announce[data-open="1"] .announce-text { + display: block; + -webkit-line-clamp: none; + line-clamp: none; + overflow: visible; +} + +.text-link { + align-self: flex-start; + min-block-size: var(--tap); + padding: 0; + border: 0; + background: none; + color: var(--accent); + font-size: var(--fs-caption); + font-weight: 700; + text-decoration: underline; + cursor: pointer; +} + +.support { + display: flex; + justify-content: center; +} + +.support-cta { min-inline-size: 220px; } + +/* --- dialogs ------------------------------------------------------------------ */ + +.dialog { + padding: 0; + border: 1px solid var(--border); + border-radius: var(--r-card); + background: var(--surface); + color: var(--text); + inline-size: calc(100% - 2 * var(--sp-4)); + max-inline-size: 360px; + box-shadow: var(--shadow); +} + +.dialog::backdrop { + background: var(--scrim); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} + +.dialog[open] { animation: dialog-in var(--dur) var(--ease); } + +@keyframes dialog-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.97); + } +} + +.dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border-block-end: 1px solid var(--border); +} + +.dialog-title { + font-size: var(--fs-title); + font-weight: 800; +} + +.qr-body { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-4); +} + +/* A QR code is read against white in every theme. */ +.qr-frame { + display: grid; + place-items: center; + padding: var(--sp-3); + border-radius: var(--r-control); + background: var(--qr-bg); +} + +#qr-canvas, +#config-canvas { + inline-size: 100%; + max-inline-size: 240px; + block-size: auto; +} + +.url-field { + display: flex; + gap: var(--sp-2); +} + +.url-field input { + flex: 1 1 auto; + min-inline-size: 0; + min-block-size: var(--tap); + padding-inline: var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--r-control); + background: var(--surface-2); + font-size: var(--fs-micro); +} + +.cfg-open { align-self: stretch; } + +.cfg-conf { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.conf-text { + inline-size: 100%; + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--r-control); + background: var(--surface-2); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: var(--fs-micro); + resize: vertical; +} + +.conf-actions { + display: flex; + gap: var(--sp-2); +} + +.conf-actions .btn { flex: 1 1 0; } + +/* --- toast ---------------------------------------------------------------------- */ + +.toast { + position: fixed; + inset-block-end: var(--sp-6); + inset-inline: 0; + z-index: 70; + inline-size: fit-content; + max-inline-size: calc(100% - 2 * var(--sp-4)); + margin-inline: auto; + padding: var(--sp-2) var(--sp-5); + border-radius: var(--r-pill); + background: var(--text); + color: var(--bg); + font-size: var(--fs-caption); + font-weight: 700; + opacity: 0; + transform: translateY(18px); + transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease); +} + +.toast[data-show] { + opacity: 1; + transform: none; +} + +/* --- hover, only where hover exists ------------------------------------------ */ + +@media (hover: hover) { + .btn-outline:hover, + .icon-btn:hover, + .cfg:hover { + border-color: var(--accent); + color: var(--accent); + } + + .btn-primary:hover { background: var(--accent); } + + .btn-quiet:hover, + .menu-item:hover, + .tab:hover:not([aria-selected="true"]) { + background: var(--surface-2); + color: var(--text); + } + + .cfg-toggle:hover { + border-color: var(--accent); + color: var(--accent); + } +} diff --git a/src/templates/meter/layout.css b/src/templates/meter/layout.css new file mode 100644 index 0000000..0e2a3e4 --- /dev/null +++ b/src/templates/meter/layout.css @@ -0,0 +1,58 @@ +/* The page skeleton. Placement only. */ + +.page { + inline-size: 100%; + max-inline-size: var(--page-max); + margin-inline: auto; + padding: var(--sp-7) var(--sp-4) calc(var(--sp-7) * 2); +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-3); + margin-block-end: var(--sp-5); +} + +.greet { + display: flex; + flex-direction: column; + gap: var(--sp-3); + min-inline-size: 0; +} + +.topbar-controls { + display: flex; + gap: var(--sp-2); + flex: none; +} + +#main { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +/* Actions and the client area side by side on a wide screen; one column on a + phone, actions first, because copying the link is the common task. */ +.grid { + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + gap: var(--sp-4); + align-items: start; +} + +.side { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +@media (max-width: 680px) { + .grid { grid-template-columns: minmax(0, 1fr); } +} + +@media (max-width: 380px) { + .page { padding-inline: var(--sp-3); } +} diff --git a/src/templates/meter/layout.html b/src/templates/meter/layout.html new file mode 100644 index 0000000..1e7c03d --- /dev/null +++ b/src/templates/meter/layout.html @@ -0,0 +1,209 @@ + + + + + + + + + +{{ if .subTitle }}{{ .subTitle }}{{ else }}Subscription{{ end }} + + + + + + + + + + + + + +
+ +
+
+
+ +

+
+
+ + {{ if .enabled }}Enabled{{ else }}Disabled{{ end }} + + +
+
+
+ + +
+
+ +
+ +
+
+

Subscription status

+ {{ if .subTitle }}{{ .subTitle }}{{ end }} +
+

{{ if .remained }}{{ .remained }} remaining{{ end }}

+
+

+ + {{ .used }} + {{ if eq .totalByte 0 }}used{{ else }}used of {{ .total }}{{ end }} +

+
+
+
+

{{ if eq .expire 0 }}Never expires{{ else if lt .expire 0 }}Starts on first connection{{ else }}—{{ end }}

+

+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+

Connect

+
+
+

+
+ +
+ +
+
+

Configurations

+ +
+

+ +
+ + +
+ + + {{ if .subSupportUrl }}
Contact support
{{ end }}
+ +
+
+ +
+ + +
+

QR code

+ +
+
+
+

Scan with your client application

+
+ + +
+
+
+ + + + +
+

+ +
+
+
+

+
+ + +
+ + +
+
+ + + + + diff --git a/src/templates/meter/rtl.css b/src/templates/meter/rtl.css new file mode 100644 index 0000000..0dcd600 --- /dev/null +++ b/src/templates/meter/rtl.css @@ -0,0 +1,26 @@ +/* Direction-specific rules. Every offset is a logical property, so dir on + does the layout; what remains are the glyphs with a direction of + their own, and Latin tracking, which would break Arabic joins. The meter + fills from the start edge in both directions: the track is masked as a + whole, so its segments stay aligned either way. */ + +[dir="rtl"] .icon-chevron, +[dir="rtl"] .icon-external { + transform: scaleX(-1); +} + +[lang="fa"] .cfg-proto, +[lang="ar"] .cfg-proto, +[lang="fa"] .brand-name, +[lang="ar"] .brand-name, +[lang="fa"] .hero-big, +[lang="ar"] .hero-big { + letter-spacing: 0; +} + +/* Persian and Arabic ascenders and descenders need room the tight Latin + display leading would clip. */ +[lang="fa"] .hero-big, +[lang="ar"] .hero-big { + line-height: 1.35; +} diff --git a/src/templates/meter/tokens.css b/src/templates/meter/tokens.css new file mode 100644 index 0000000..4857f8d --- /dev/null +++ b/src/templates/meter/tokens.css @@ -0,0 +1,148 @@ +/* Design tokens. Every value a theme can change lives here and nowhere else; + the rest of the stylesheet only ever reads custom properties. + + Meter is the dashboard of the catalogue: near-black planes, one electric + blue, and a single segmented meter that reads like an equalizer. It is a + port of a design contributed by the project's author ("Pulse"), rebuilt on + the shared runtime: every figure, label and control comes from Row's hooks. */ + +:root { + --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + --font-arabic: Vazirmatn, "Segoe UI", Tahoma, "Noto Naskh Arabic", + "Geeza Pro", sans-serif; + + --fs-hero: clamp(2.25rem, 9vw, 3.75rem); + --fs-title: 0.9375rem; + --fs-body: 0.9375rem; + --fs-caption: 0.8125rem; + --fs-micro: 0.75rem; + + --lh-hero: 1.05; + --lh-tight: 1.35; + --lh-body: 1.6; + --measure: 60ch; + + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-7: 32px; + + --r-card: 16px; + --r-control: 11px; + --r-seg: 3px; + --r-pill: 999px; + + --dur-fast: 120ms; + --dur: 180ms; + --dur-slow: 420ms; + --ease: cubic-bezier(0.4, 0, 0.2, 1); + --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1); + + --page-max: 880px; + --tap: 44px; + --mark: 36px; + --btn-h: 44px; + + /* The meter: a track cut into equal segments, filled from the start. */ + --meter-h: 72px; + --seg: 7px; + --seg-gap: 3px; +} + +[data-theme="dark"] { + color-scheme: dark; + + --bg: #09090B; + --surface: #111113; + --surface-2: #1A1A1E; + --border: #212126; + --border-strong: #34343B; + + --text: #FAFAFA; + --muted: #A1A1AA; + --subtle: #8B8B94; + + --accent: #3B82F6; + --accent-strong: #2563EB; + --accent-soft: #14213A; + --on-accent: #FFFFFF; + + --meter-off: #1C1C21; + --meter-top: #60A5FA; + --meter-bottom: #2563EB; + --meter-warn-top: #FBBF24; + --meter-warn-bottom: #D97706; + --meter-over-top: #F87171; + --meter-over-bottom: #DC2626; + --glow: rgb(59 130 246 / 0.24); + + --success: #22C55E; + --warning: #F59E0B; + --danger: #EF4444; + --pending: #A78BFA; + --focus: #60A5FA; + --qr-bg: #FFFFFF; + --scrim: rgb(0 0 0 / 0.62); + --shadow: 0 18px 44px rgb(0 0 0 / 0.45); +} + +[data-theme="light"] { + color-scheme: light; + + --bg: #F4F4F6; + --surface: #FFFFFF; + --surface-2: #F1F1F4; + --border: #E4E4E9; + --border-strong: #CACAD2; + + --text: #0C0C0E; + --muted: #55555F; + --subtle: #6A6A74; + + --accent: #2563EB; + --accent-strong: #1D4ED8; + --accent-soft: #E3ECFD; + --on-accent: #FFFFFF; + + --meter-off: #E6E6EC; + --meter-top: #3B82F6; + --meter-bottom: #1D4ED8; + --meter-warn-top: #F59E0B; + --meter-warn-bottom: #B45309; + --meter-over-top: #EF4444; + --meter-over-bottom: #B91C1C; + --glow: rgb(37 99 235 / 0.16); + + --success: #15803D; + --warning: #B45309; + --danger: #B91C1C; + --pending: #6D28D9; + --focus: #2563EB; + --qr-bg: #FFFFFF; + --scrim: rgb(12 12 14 / 0.45); + --shadow: 0 18px 44px rgb(12 12 14 / 0.16); +} + +/* The Arabic-script face is opted into by language, and its unicode-range + keeps it off Latin, Cyrillic and CJK text even here. */ +[lang="fa"], +[lang="ar"] { + --font: var(--font-arabic); + --lh-body: 1.85; +} + +/* row:font-face */ +@font-face { + font-family: Vazirmatn; + src: url("data:font/woff2;base64,__FONT_BASE64__") format("woff2"); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: U+0600-06FF, U+200C-200F, U+2066-2069, U+FB50-FDFF, + U+FE70-FEFF; +} +/* row:font-face end */ diff --git a/src/templates/notebook/base.css b/src/templates/notebook/base.css new file mode 100644 index 0000000..521618b --- /dev/null +++ b/src/templates/notebook/base.css @@ -0,0 +1,120 @@ +/* Reset, document shell and the primitives everything else builds on. */ + +*, +*::before, +*::after { box-sizing: border-box; } + +html { + background: var(--paper); + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +/* Dotted notebook paper. */ +body { + margin: 0; + min-height: 100vh; + font-family: var(--font); + font-size: var(--fs-body); + line-height: var(--lh-body); + color: var(--ink); + background-color: var(--paper); + background-image: radial-gradient(var(--paper-dot) 1px, transparent 1.5px); + background-size: var(--dots) var(--dots); + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, p, ul, ol { + margin: 0; + font-weight: inherit; + font-size: inherit; +} + +ul, ol { padding: 0; list-style: none; } + +a { color: var(--blue); } + +button { + margin: 0; + font: inherit; + color: inherit; + font-variant-numeric: inherit; +} + +input, textarea { + font: inherit; + color: inherit; +} + +canvas { display: block; } + +svg.sprite { display: none; } + +.icon { + inline-size: 1.0625rem; + block-size: 1.0625rem; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; +} + +.announce-text, +#connect-hint { max-inline-size: var(--measure); } + +.ltr { + direction: ltr; + unicode-bidi: isolate; +} + +.visually-hidden { + position: absolute; + inline-size: 1px; + block-size: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +html:not([data-js]) .js-only { display: none !important; } + +/* The tap floor, stated on element types so no control can slip under it: + every button, link-button, tab and field is at least 44px tall. */ +button, +a.btn, +[role="tab"], +input { min-block-size: var(--tap); } + +:focus-visible { + outline: 2.5px dashed var(--focus); + outline-offset: 3px; +} + +::selection { + background: var(--yellow); + color: var(--on-yellow); +} + +@keyframes ink-in { + from { + opacity: 0; + transform: scale(0.92) rotate(-2deg); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/src/templates/notebook/components.css b/src/templates/notebook/components.css new file mode 100644 index 0000000..d799a49 --- /dev/null +++ b/src/templates/notebook/components.css @@ -0,0 +1,861 @@ +/* The component vocabulary. */ + +[hidden] { display: none !important; } + +/* --- cards: ink outline, washi tape, a slight tilt ----------------------- */ + +.card { + position: relative; + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-5) var(--sp-4) var(--sp-4); + background: var(--card); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-card-a); + box-shadow: var(--shadow); + transform: rotate(0.2deg); +} + +.card-expiry, +.card-explorer { + border-radius: var(--r-card-b); + transform: rotate(-0.25deg); +} + +.tape { + position: absolute; + inset-block-start: -10px; + inset-inline-start: calc(50% - 37px); + inline-size: 74px; + block-size: 16px; + border-radius: 2px; + box-shadow: 0 1px 3px var(--hard); + transform: rotate(-3deg); +} + +.tape-green { background: var(--tape-green); } +.tape-blue { background: var(--tape-blue); } +.tape-yellow { background: var(--tape-yellow); } +.tape-purple { background: var(--tape-purple); } + +.card-title { + font-size: var(--fs-title); + font-weight: 800; + line-height: var(--lh-tight); +} + +/* A highlighter stroke behind a heading. */ +.marker { + padding-inline: 7px; + background: linear-gradient(104deg, transparent 1%, var(--mark-yellow) 2.5%, var(--mark-yellow) 97%, transparent 99%); + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +.marker-green { + background: linear-gradient(104deg, transparent 1%, var(--mark-green) 2.5%, var(--mark-green) 97%, transparent 99%); +} + +.marker-purple { + background: linear-gradient(104deg, transparent 1%, var(--mark-purple) 2.5%, var(--mark-purple) 97%, transparent 99%); +} + +.note { + font-size: var(--fs-caption); + color: var(--ink-soft); + font-weight: 600; +} + +.note:empty { display: none; } + +/* --- brand -------------------------------------------------------------- */ + +.brand { + display: flex; + align-items: center; + gap: var(--sp-2); + min-inline-size: 0; + transform: rotate(-1.5deg); +} + +.brand-mark { + flex: none; + display: grid; + place-items: center; + inline-size: var(--mark); + block-size: var(--mark); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-blob); + background: var(--card-2); + font-weight: 900; + overflow: hidden; + transform: rotate(-3deg); +} + +.brand-mark img { + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +.brand-name { + min-inline-size: 0; + font-family: var(--hand); + font-size: 1.5rem; + font-weight: 700; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.plan-label { + display: inline-block; + padding: 2px var(--sp-3); + border: 2px solid var(--line); + border-radius: var(--r-chip); + background: var(--card-2); + font-size: var(--fs-micro); + font-weight: 800; + transform: rotate(-1deg); +} + +/* --- the status, written large ------------------------------------------ */ + +.hero { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-4); + text-align: center; +} + +.status-word { + display: inline-block; + padding-block-end: 6px; + color: var(--green); + font-size: var(--fs-status); + font-weight: 900; + line-height: 1.1; + letter-spacing: -0.02em; + text-decoration: underline wavy 4px var(--green); + text-underline-offset: 12px; + animation: ink-in var(--dur-slow) var(--ease); +} + +.status-word .dot { display: none; } + +.status-word[data-state="limited"] { + color: var(--orange); + text-decoration-color: var(--orange); +} + +.status-word[data-state="expired"], +.status-word[data-state="disabled"] { + color: var(--red); + text-decoration-color: var(--red); +} + +.hero-note { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--sp-2) var(--sp-3); + color: var(--ink-soft); + font-size: var(--fs-caption); + font-weight: 700; +} + +.live-state { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.live-state:empty { display: none; } + +.live-state .dot { + inline-size: 9px; + block-size: 9px; + border: 2px solid var(--line); + border-radius: 50%; + background: var(--card-2); +} + +.live-state[data-online="1"] .dot { background: var(--green); } + +/* --- buttons: ink outline with a hard offset shadow ----------------------- */ + +.btn, +.sk-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-block-size: var(--btn-h); + padding: var(--sp-2) var(--sp-4); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-btn); + background: var(--card); + color: var(--ink); + font-size: var(--fs-caption); + font-weight: 800; + line-height: var(--lh-tight); + text-decoration: none; + box-shadow: var(--shadow); + cursor: pointer; + transition: transform var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease); +} + +.btn:active, +.sk-btn:active { + transform: translate(2px, 2px); + box-shadow: 1px 1px 0 var(--hard); +} + +.btn-primary { + background: var(--yellow); + color: var(--on-yellow); +} + +.btn-quiet { + border-color: transparent; + background: transparent; + box-shadow: none; + color: var(--ink-soft); +} + +.btn-sm { + min-block-size: var(--tap); + min-inline-size: 64px; + padding-inline: var(--sp-3); + font-size: var(--fs-micro); +} + +.btn:disabled { + opacity: 0.5; + cursor: default; +} + +.sk-btn { + min-inline-size: var(--tap); + min-block-size: var(--tap); + padding-inline: var(--sp-2); + font-size: var(--fs-micro); +} + +.sk-btn-icon { padding: 0; } + +.sk-btn[aria-expanded="true"] { color: var(--blue); border-color: var(--blue); } + +.btn-swap { + display: grid; + grid-template-areas: "label"; + place-items: center; + min-inline-size: 0; +} + +.btn-swap > span { + grid-area: label; + max-inline-size: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.btn-swap > .swap-done { visibility: hidden; } + +.btn[data-flash] .swap-live { visibility: hidden; } + +.btn[data-flash] .swap-done { visibility: visible; } + +.btn[data-flash] { + border-color: var(--green); + color: var(--green); +} + +.btn-primary[data-flash] { color: var(--on-yellow); } + +.icon-chevron { + inline-size: 0.875rem; + block-size: 0.875rem; +} + +/* --- menus ------------------------------------------------------------------ */ + +.menu-wrap { position: relative; } + +.menu { + position: absolute; + inset-block-start: calc(100% + var(--sp-2)); + inset-inline-end: 0; + z-index: 20; + min-inline-size: 180px; + padding: var(--sp-1); + background: var(--card); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + box-shadow: var(--shadow-lg); +} + +.menu-item { + display: flex; + align-items: center; + gap: var(--sp-2); + inline-size: 100%; + min-block-size: var(--tap); + padding-inline: var(--sp-3); + border: 0; + border-radius: var(--r-chip); + background: transparent; + font-size: var(--fs-caption); + font-weight: 700; + text-align: start; + cursor: pointer; +} + +.menu-item .icon:last-child { + margin-inline-start: auto; + opacity: 0; +} + +.menu-item[aria-checked="true"] { color: var(--blue); } + +.menu-item[aria-checked="true"] .icon:last-child { opacity: 1; } + +/* --- usage ------------------------------------------------------------------ */ + +.usage-values { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-1) var(--sp-3); +} + +.usage-big { + font-size: 1.5rem; + font-weight: 900; +} + +.usage-side { + color: var(--ink-soft); + font-size: var(--fs-caption); + font-weight: 700; +} + +.usage-bar:empty { display: none; } + +.bar-row { + display: flex; + align-items: center; + gap: var(--sp-3); +} + +.bar { + position: relative; + flex: 1 1 auto; + display: block; + block-size: 26px; + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + background: var(--card-2); + overflow: hidden; + transform: rotate(-0.3deg); +} + +/* Hand-hatched ink fill. */ +.bar-fill { + display: block; + block-size: 100%; + border-radius: inherit; + background: repeating-linear-gradient(-45deg, var(--green) 0 9px, var(--green-dk) 9px 11px); + transition: inline-size var(--dur-slow) var(--ease); +} + +.bar[data-level="warn"] .bar-fill { + background: repeating-linear-gradient(-45deg, var(--yellow) 0 9px, var(--yellow-dk) 9px 11px); +} + +.bar[data-level="over"] .bar-fill { + background: repeating-linear-gradient(-45deg, var(--red) 0 9px, var(--red-dk) 9px 11px); +} + +.bar-pct { + flex: none; + font-family: var(--hand); + font-size: 1.0625rem; + font-weight: 700; +} + +/* --- expiry ----------------------------------------------------------------- */ + +.expiry-row { + display: flex; + align-items: center; + gap: var(--sp-4); +} + +.clock-doodle { + flex: none; + inline-size: 54px; + block-size: 54px; + fill: none; + stroke: var(--blue); + stroke-width: 2.4; + stroke-linecap: round; +} + +.clock-arc { opacity: 0.35; } + +.expiry-text { + display: flex; + flex-direction: column; + gap: 2px; + min-inline-size: 0; +} + +.expiry-big { + color: var(--blue); + font-size: clamp(1.25rem, 6vw, 1.625rem); + font-weight: 900; + line-height: var(--lh-tight); + transform: rotate(-0.4deg); +} + +.expiry-big[data-level="warn"] { color: var(--orange); } + +.expiry-big[data-level="over"] { color: var(--red); } + +.stamp { + align-self: flex-end; + font-family: var(--hand); + font-size: var(--fs-caption); + color: var(--ink-soft); +} + +.stamp:empty { display: none; } + +.stamp[data-stale] { color: var(--red); } + +/* --- the client area: app tiles -------------------------------------------- */ + +.tabs { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); +} + +.tab { + min-block-size: var(--tap); + padding-inline: var(--sp-3); + border: 2px solid var(--line); + border-radius: var(--r-chip); + background: var(--card-2); + font-size: var(--fs-micro); + font-weight: 800; + cursor: pointer; +} + +.tab[aria-selected="true"] { + background: var(--blue); + border-color: var(--blue); + color: var(--on-color); + transform: rotate(-1.5deg); +} + +.clients { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); + gap: var(--sp-3); +} + +.client { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-3); + border: var(--ink-w) solid var(--line); + border-radius: 18px 12px 255px 16px / 14px 255px 15px 225px; + background: var(--card-2); + box-shadow: var(--shadow); + transform: rotate(-0.2deg); +} + +.client:nth-child(even) { + border-radius: var(--r-card-a); + transform: rotate(0.3deg); +} + +.client-head { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.client-name { + flex: 1 1 auto; + font-size: var(--fs-caption); + font-weight: 900; +} + +.client-tag { + font-family: var(--hand); + font-size: var(--fs-caption); + color: var(--green); +} + +.client-actions { + display: flex; + gap: var(--sp-2); +} + +.client-actions .btn { flex: 1 1 0; } + +/* --- configurations: sticky notes ------------------------------------------ */ + +.explorer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); +} + +.cfg-count { + font-family: var(--hand); + font-size: 1.125rem; + font-weight: 700; + color: var(--purple); +} + +.cfg-count:empty { display: none; } + +.cfg-search { + display: flex; + align-items: center; + gap: var(--sp-2); + padding-inline: var(--sp-3); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + background: var(--card-2); +} + +.cfg-search input { + flex: 1 1 auto; + min-inline-size: 0; + block-size: var(--tap); + border: 0; + background: transparent; + outline: none; +} + +.cfg-list { + display: flex; + flex-direction: column; + gap: var(--sp-4); + padding-block-start: var(--sp-2); +} + +.cfg { + position: relative; + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-3); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + background: var(--card); + box-shadow: var(--shadow); + transform: rotate(0.2deg); +} + +.cfg:nth-child(even) { transform: rotate(-0.25deg); } + +.cfg::before { + content: ""; + position: absolute; + inset-block-start: -8px; + inset-inline-end: var(--sp-4); + inline-size: 56px; + block-size: 13px; + border-radius: 2px; + background: var(--tape-pink); + transform: rotate(-5deg); +} + +.cfg:nth-child(3n + 2)::before { background: var(--tape-blue); } +.cfg:nth-child(3n)::before { background: var(--tape-green); } + +.cfg-flag { + flex: none; + display: grid; + place-items: center; + inline-size: 38px; + block-size: 38px; + border: 2px solid var(--line); + border-radius: var(--r-chip); + background: var(--card-2); + font-size: 18px; + line-height: 1; + overflow: hidden; + transform: rotate(-2deg); +} + +.cfg-flag[data-mono] { + background: var(--blue); + color: var(--on-color); + font-family: var(--hand); + font-size: 1.0625rem; + font-weight: 700; +} + +.cfg-body { + flex: 1 1 auto; + min-inline-size: 0; + display: flex; + flex-direction: column; +} + +.cfg-name { + font-size: var(--fs-caption); + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cfg-proto { + font-family: var(--hand); + font-size: var(--fs-caption); + color: var(--ink-soft); +} + +.cfg-actions { + flex: none; + display: flex; + gap: 6px; +} + +.cfg-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + min-block-size: var(--tap); + border: var(--ink-w) dashed var(--line); + border-radius: var(--r-btn); + background: transparent; + font-size: var(--fs-caption); + font-weight: 800; + cursor: pointer; +} + +.cfg-toggle[aria-expanded="true"] .icon { transform: rotate(180deg); } + +/* --- announcement: a yellow sticky note; support ---------------------------- */ + +.announce { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-4); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + background: var(--mark-yellow); + box-shadow: var(--shadow); + transform: rotate(-0.6deg); +} + +.announce-head { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.section-title { + font-size: var(--fs-caption); + font-weight: 900; +} + +.announce-text { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + line-clamp: 4; + overflow: hidden; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--fs-caption); + font-weight: 600; +} + +.announce[data-open="1"] .announce-text { + display: block; + -webkit-line-clamp: none; + line-clamp: none; + overflow: visible; +} + +.text-link { + align-self: flex-start; + min-block-size: var(--tap); + padding: 0; + border: 0; + background: none; + color: var(--blue); + font-family: var(--hand); + font-size: 1rem; + font-weight: 700; + text-decoration: underline wavy 2px; + text-underline-offset: 4px; + cursor: pointer; +} + +.support { + display: flex; + justify-content: center; +} + +.support-cta { min-inline-size: 220px; } + +/* --- dialogs ---------------------------------------------------------------- */ + +.dialog { + padding: 0; + border: var(--ink-w) solid var(--line); + border-radius: var(--r-card-a); + background: var(--card); + color: var(--ink); + inline-size: calc(100% - 2 * var(--sp-4)); + max-inline-size: 340px; + box-shadow: var(--shadow-lg); +} + +.dialog::backdrop { background: var(--scrim); } + +.dialog[open] { animation: ink-in var(--dur) var(--ease); } + +.dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-4) var(--sp-4) 0; +} + +.dialog-title { + font-size: var(--fs-title); + font-weight: 900; +} + +.qr-body { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-4); +} + +.qr-frame { + display: grid; + place-items: center; + padding: var(--sp-3); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + background: var(--qr-bg); +} + +#qr-canvas, +#config-canvas { + inline-size: 100%; + max-inline-size: 220px; + block-size: auto; +} + +.url-field { + display: flex; + gap: var(--sp-2); +} + +.url-field input { + flex: 1 1 auto; + min-inline-size: 0; + min-block-size: var(--tap); + padding-inline: var(--sp-3); + border: 2px solid var(--line); + border-radius: var(--r-chip); + background: var(--card-2); + font-size: var(--fs-micro); +} + +.cfg-open { align-self: stretch; } + +.cfg-conf { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.conf-text { + inline-size: 100%; + padding: var(--sp-2) var(--sp-3); + border: 2px solid var(--line); + border-radius: var(--r-chip); + background: var(--card-2); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: var(--fs-micro); + resize: vertical; +} + +.conf-actions { + display: flex; + gap: var(--sp-2); +} + +.conf-actions .btn { flex: 1 1 0; } + +/* --- toast -------------------------------------------------------------------- */ + +.toast { + position: fixed; + inset-block-end: var(--sp-6); + inset-inline: 0; + z-index: 70; + inline-size: fit-content; + max-inline-size: calc(100% - 2 * var(--sp-4)); + margin-inline: auto; + padding: var(--sp-3) var(--sp-5); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-btn); + background: var(--card); + color: var(--ink); + font-size: var(--fs-caption); + font-weight: 800; + box-shadow: 4px 4px 0 var(--hard); + opacity: 0; + transform: translateY(18px) rotate(-1deg); + transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease); +} + +.toast[data-show] { + opacity: 1; + transform: rotate(-1deg); +} + +/* --- hover, only where hover exists ------------------------------------------ */ + +@media (hover: hover) { + .btn-outline:hover, + .sk-btn:hover, + .cfg-toggle:hover { + border-color: var(--blue); + color: var(--blue); + } + + .btn-primary:hover { background: var(--yellow-dk); } + + .btn-quiet:hover, + .menu-item:hover { background: var(--card-2); } + + .tab:hover:not([aria-selected="true"]) { border-color: var(--blue); } +} diff --git a/src/templates/notebook/layout.css b/src/templates/notebook/layout.css new file mode 100644 index 0000000..075854e --- /dev/null +++ b/src/templates/notebook/layout.css @@ -0,0 +1,80 @@ +/* The page skeleton. Placement only. */ + +.page { + position: relative; + z-index: 1; + inline-size: 100%; + max-inline-size: var(--page-max); + margin-inline: auto; + padding: var(--sp-6) var(--sp-4) calc(var(--sp-7) * 2); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + margin-block-end: var(--sp-5); +} + +.topbar-controls { + display: flex; + gap: var(--sp-2); + flex: none; +} + +#main { + display: flex; + flex-direction: column; + gap: var(--sp-6); +} + +.actions { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: var(--sp-3); +} + +/* Background doodles sit behind the page in the margins, never over text. */ +.doodles { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; +} + +.doodle { + position: absolute; + inline-size: 48px; + block-size: 48px; + fill: none; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; + opacity: var(--doodle-opacity); +} + +.doodle-star { + inset-block-start: 72px; + inset-inline-start: 12px; + stroke: var(--yellow); +} + +.doodle-spiral { + inset-block-end: 40px; + inset-inline-start: 16px; + stroke: var(--pink); +} + +.doodle-squiggle { + inset-block-start: 38%; + inset-inline-end: -8px; + inline-size: 70px; + block-size: 40px; + stroke: var(--blue); +} + +@media (max-width: 380px) { + .page { padding-inline: var(--sp-3); } + .actions { grid-template-columns: minmax(0, 1fr); } +} diff --git a/src/templates/notebook/layout.html b/src/templates/notebook/layout.html new file mode 100644 index 0000000..e935797 --- /dev/null +++ b/src/templates/notebook/layout.html @@ -0,0 +1,207 @@ + + + + + + + + + +{{ if .subTitle }}{{ .subTitle }}{{ else }}Subscription{{ end }} + + + + + + + + + + + + + + + +
+ +
+
+ +

+
+
+ + +
+
+ +
+ +
+

{{ if .enabled }}Enabled{{ else }}Disabled{{ end }}

+

+ + {{ if .subTitle }}{{ .subTitle }}{{ end }} +

+
+ +
+ + +
+ +
+ +

Subscription status

+
+

{{ .used }}

+

{{ if eq .totalByte 0 }}used{{ else }}used of {{ .total }}{{ end }}

+
+
+

{{ if .remained }}{{ .remained }} remaining{{ end }}

+
+ +
+ +
+ +
+

{{ if eq .expire 0 }}Never expires{{ else if lt .expire 0 }}Starts on first connection{{ else }}—{{ end }}

+

+
+
+ +
+ +
+ +

Connect

+
+
+

+
+ +
+ +
+

Configurations

+ +
+

+ +
+ + +
+ + + {{ if .subSupportUrl }}
Contact support
{{ end }}
+ +
+
+ +
+ + +
+

QR code

+ +
+
+
+

Scan with your client application

+
+ + +
+
+
+ + + + +
+

+ +
+
+
+

+
+ + +
+ + +
+
+ + + + + diff --git a/src/templates/notebook/rtl.css b/src/templates/notebook/rtl.css new file mode 100644 index 0000000..e407af1 --- /dev/null +++ b/src/templates/notebook/rtl.css @@ -0,0 +1,17 @@ +/* Direction-specific rules. Every offset is a logical property, so dir on + does the layout; what remains are the glyphs with a direction of + their own, the hand tilt (mirrored so the page leans the same way to a + right-to-left reader), and Latin tracking, which breaks Arabic joins. */ + +[dir="rtl"] .icon-chevron, +[dir="rtl"] .icon-external { + transform: scaleX(-1); +} + +[dir="rtl"] .brand { transform: rotate(1.5deg); } + +[lang="fa"] .status-word, +[lang="ar"] .status-word { + letter-spacing: 0; + line-height: 1.35; +} diff --git a/src/templates/notebook/tokens.css b/src/templates/notebook/tokens.css new file mode 100644 index 0000000..1e2ddfc --- /dev/null +++ b/src/templates/notebook/tokens.css @@ -0,0 +1,161 @@ +/* Design tokens. Every value a theme can change lives here and nowhere else; + the rest of the stylesheet only ever reads custom properties. + + Notebook is the hand-made page of the catalogue: dotted paper, ink-drawn + cards held on with washi tape, a status written large and underlined by + hand. It is a port of a design contributed by the project's author + ("Sketch"), rebuilt on the shared runtime. The handwriting accent uses the + system's own script faces: nothing is fetched. */ + +:root { + --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + --font-arabic: Vazirmatn, "Segoe UI", Tahoma, "Noto Naskh Arabic", + "Geeza Pro", sans-serif; + --hand: "Segoe Print", "Bradley Hand", "Chalkboard SE", "Marker Felt", + "Comic Sans MS", cursive; + + --fs-status: clamp(2.75rem, 14vw, 4.5rem); + --fs-title: 1rem; + --fs-body: 0.9375rem; + --fs-caption: 0.8125rem; + --fs-micro: 0.75rem; + + --lh-tight: 1.35; + --lh-body: 1.6; + --measure: 60ch; + + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-7: 32px; + + /* Ink lines are never quite straight: every shape has an uneven radius. */ + --ink-w: 2.5px; + --r-card-a: 255px 15px 225px 15px / 15px 225px 15px 255px; + --r-card-b: 15px 255px 15px 225px / 225px 15px 255px 15px; + --r-btn: 20px 10px 18px 12px / 12px 18px 10px 20px; + --r-chip: 14px 8px 12px 10px / 10px 12px 8px 14px; + --r-note: 12px 20px 14px 18px / 18px 12px 20px 14px; + --r-blob: 50% 42% 48% 40% / 46% 44% 50% 42%; + + --dur-fast: 120ms; + --dur: 180ms; + --dur-slow: 500ms; + --ease: ease; + + --page-max: 540px; + --tap: 44px; + --mark: 40px; + --btn-h: 44px; + --dots: 22px; +} + +[data-theme="light"] { + color-scheme: light; + + --paper: #F6F1E5; + --paper-dot: rgb(70 62 46 / 0.12); + --card: #FFFDF6; + --card-2: #FBF5E8; + --ink: #292724; + --ink-soft: #625B4E; + --line: #292724; + + --red: #C8432B; + --blue: #3470C4; + --yellow: #EAB93A; + --green: #3B8551; + --purple: #7F52B0; + --pink: #C85C8A; + --orange: #C9742A; + --green-dk: #2F6B42; + --yellow-dk: #C99A24; + --red-dk: #A8361F; + + --tape-pink: rgb(221 111 156 / 0.55); + --tape-blue: rgb(63 127 214 / 0.5); + --tape-green: rgb(70 148 92 / 0.5); + --tape-yellow: rgb(234 185 58 / 0.6); + --tape-purple: rgb(143 95 192 / 0.5); + --mark-green: rgb(70 148 92 / 0.25); + --mark-yellow: rgb(234 185 58 / 0.7); + --mark-purple: rgb(143 95 192 / 0.22); + + --hard: rgb(41 39 36 / 0.14); + --shadow: 3px 3px 0 rgb(41 39 36 / 0.16); + --shadow-lg: 6px 6px 0 rgb(0 0 0 / 0.2); + --on-color: #FFFFFF; + --on-yellow: #292724; + --qr-bg: #FFFFFF; + --scrim: rgb(41 39 36 / 0.55); + --focus: #3470C4; + --doodle-opacity: 0.5; +} + +[data-theme="dark"] { + color-scheme: dark; + + --paper: #1F1C17; + --paper-dot: rgb(240 232 212 / 0.08); + --card: #2A261F; + --card-2: #322D24; + --ink: #F0E8D4; + --ink-soft: #B3AA96; + --line: #F0E8D4; + + --red: #FF7A5C; + --blue: #6FA8FF; + --yellow: #FFD75E; + --green: #82D69C; + --purple: #C99AFF; + --pink: #FF9CC4; + --orange: #FFAB5C; + --green-dk: #5FBF7E; + --yellow-dk: #F0C23F; + --red-dk: #FF5C3A; + + --tape-pink: rgb(255 156 196 / 0.45); + --tape-blue: rgb(111 168 255 / 0.42); + --tape-green: rgb(130 214 156 / 0.4); + --tape-yellow: rgb(255 215 94 / 0.5); + --tape-purple: rgb(201 154 255 / 0.42); + --mark-green: rgb(130 214 156 / 0.25); + --mark-yellow: rgb(255 215 94 / 0.4); + --mark-purple: rgb(201 154 255 / 0.25); + + --hard: rgb(240 232 212 / 0.12); + --shadow: 3px 3px 0 rgb(0 0 0 / 0.35); + --shadow-lg: 6px 6px 0 rgb(0 0 0 / 0.45); + --on-color: #1F1C17; + --on-yellow: #1F1C17; + --qr-bg: #FFFFFF; + --scrim: rgb(0 0 0 / 0.6); + --focus: #6FA8FF; + --doodle-opacity: 0.35; +} + +/* The Arabic-script face is opted into by language, and its unicode-range + keeps it off Latin, Cyrillic and CJK text even here. A Latin script face + has no Arabic glyphs, so the handwriting accent falls back to the body. */ +[lang="fa"], +[lang="ar"] { + --font: var(--font-arabic); + --hand: var(--font-arabic); + --lh-body: 1.85; +} + +/* row:font-face */ +@font-face { + font-family: Vazirmatn; + src: url("data:font/woff2;base64,__FONT_BASE64__") format("woff2"); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: U+0600-06FF, U+200C-200F, U+2066-2069, U+FB50-FDFF, + U+FE70-FEFF; +} +/* row:font-face end */ diff --git a/tests/adapters-pasarguard.test.mjs b/tests/adapters-pasarguard.test.mjs index 27e9dd4..26a1f71 100644 --- a/tests/adapters-pasarguard.test.mjs +++ b/tests/adapters-pasarguard.test.mjs @@ -38,7 +38,7 @@ const withClock = (doc) => ({ ...doc.native, now: doc.source.clock * 1000 }); test('every fixture reproduces its expected model exactly', () => { for (const { file, doc } of FIXTURES) { - if (doc.expected.model === null) continue; /* deferred: on_hold */ + if (doc.expected.model === null) continue; /* none since 1.3.0 */ const got = island(withClock(doc)); assert.deepEqual(got, doc.expected.model, file + ': the adapter must match the fixture'); } @@ -175,15 +175,26 @@ test('a seconds/milliseconds swap would be caught', () => { assert.equal(m.expire * 1000 === m.lastOnline, false, 'they are not the same instant'); }); -/* 14 — on_hold */ -test('on_hold is refused explicitly, never coerced to another state', () => { +/* 14 — on_hold (decided for 1.3.0, docs/design/PANEL-ON-HOLD-DECISION.md) */ +test('on_hold resolves to a pending expiry of the hold duration, never a tempting wrong answer', () => { const doc = byCase('08-on-hold').doc; assert.equal(doc.native.info.status, 'on_hold'); - assert.throws(() => island(withClock(doc)), /unsupported on_hold state/); - /* And specifically NOT any of the tempting wrong answers. */ - for (const wrong of ['disabled', 'active']) { - assert.notEqual(doc.native.info.status, wrong); + const m = island(withClock(doc)); + assert.deepEqual(m, doc.expected.model); + assert.equal(m.enabled, true, 'not disabled'); + assert.equal(m.expire, -doc.native.info.on_hold_expire_duration, 'the clock starts on first connection'); + const noDuration = island(withClock(byCase('22-on-hold-no-duration').doc)); + assert.equal(noDuration.expire, null, 'without a duration the expiry is unknown, not 0 ("never")'); +}); + +test('limited and expired are enabled statuses, and a status outside the enum is refused', () => { + for (const kase of ['20-status-limited', '21-status-expired']) { + const doc = byCase(kase).doc; + assert.equal(island(withClock(doc)).enabled, true, kase); } + const doc = byCase('01-active-online').doc; + const bad = { ...doc.native, info: { ...doc.native.info, status: 'suspended' }, now: doc.source.clock * 1000 }; + assert.throws(() => island(bad), /unknown status "suspended"/); }); /* 15 — malformed payload */ @@ -257,7 +268,7 @@ test('the adapter is deterministic and does not mutate its input', () => { /* --- nothing was rebuilt ------------------------------------------------ */ -test('the 15 artifacts are byte-identical to their committed locks', async () => { +test('the 17 artifacts are byte-identical to their committed locks', async () => { const { build } = await import('../tools/build.mjs'); const { templateIds } = await import('../tools/templates.mjs'); const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); @@ -267,7 +278,7 @@ test('the 15 artifacts are byte-identical to their committed locks', async () => const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(build(true, id).html, 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/adapters-rebecca.test.mjs b/tests/adapters-rebecca.test.mjs index 89cd374..172f994 100644 --- a/tests/adapters-rebecca.test.mjs +++ b/tests/adapters-rebecca.test.mjs @@ -35,10 +35,10 @@ const FIXTURES = readdirSync(DIR).filter((f) => f.endsWith('.json')).sort() const byCase = (name) => FIXTURES.find((f) => f.doc.case === name); const withClock = (doc) => ({ ...doc.native, now: doc.source.clock * 1000 }); -/* The fixtures whose expectation is deliberately absent: `on_hold` and the - unknown status. Both must THROW, so they are excluded from the oracle sweep - and covered by their own tests below. */ -const THROWS = ['08-on-hold', '17-unknown-status']; +/* The fixture whose expectation is deliberately absent: the unknown status. + It must THROW, so it is excluded from the oracle sweep and covered by its own + test below. (`on_hold` resolves since 1.3.0 and is swept like any other.) */ +const THROWS = ['17-unknown-status']; /* --- the oracle sweep ---------------------------------------------------- */ @@ -106,15 +106,13 @@ test('disabled is the ONLY status that means off', () => { /* --- on_hold and unknown status both throw ------------------------------- */ -test('on_hold is refused explicitly, never coerced to another state', () => { +test('on_hold resolves to enabled with an unknown expiry, never a tempting wrong answer', () => { const doc = byCase('08-on-hold').doc; assert.equal(doc.native.info.status, 'on_hold'); - assert.equal(doc.expected.model, null, 'the fixture records no expectation'); - assert.throws(() => island(withClock(doc)), /unsupported on_hold state/); - /* And specifically NOT any of the tempting wrong answers. */ - for (const wrong of ['disabled', 'active', 'expired']) { - assert.notEqual(doc.native.info.status, wrong); - } + const m = island(withClock(doc)); + assert.deepEqual(m, doc.expected.model); + assert.equal(m.enabled, true, 'not disabled'); + assert.equal(m.expire, null, 'not 0 ("never"), and not an invented duration'); }); test('an unknown status is refused rather than absorbed by an else branch', () => { @@ -127,7 +125,7 @@ test('an unknown status is refused rather than absorbed by an else branch', () = assert.throws(() => island(withClock(doc)), /suspended/); }); -test('the two refusals are the only fixtures without an expectation', () => { +test('the refusal is the only fixture without an expectation', () => { const deferred = FIXTURES.filter((f) => f.doc.expected.model === null).map((f) => f.doc.case); assert.deepEqual(deferred, THROWS); }); @@ -198,6 +196,11 @@ test('expire passes through as SECONDS, unchanged', () => { for (const { file, doc: d } of FIXTURES) { if (THROWS.includes(d.case)) continue; const e = d.native.info.expire; + if (d.native.info.status === 'on_hold') { + /* the clock has not started and Rebecca passes no hold duration */ + assert.equal(island(withClock(d)).expire, null, file + ': an on_hold expiry is unknown'); + continue; + } assert.equal(island(withClock(d)).expire, e === null || e <= 0 ? 0 : e, file + ': expire must be DIRECT, never converted'); } @@ -331,7 +334,7 @@ test('the other two panels are untouched by the activation', () => { /* --- nothing was rebuilt ------------------------------------------------- */ -test('the 15 artifacts are byte-identical to their committed locks', async () => { +test('the 17 artifacts are byte-identical to their committed locks', async () => { const { build } = await import('../tools/build.mjs'); const { templateIds } = await import('../tools/templates.mjs'); const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); @@ -341,7 +344,7 @@ test('the 15 artifacts are byte-identical to their committed locks', async () => const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(build(true, id).html, 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/adapters.test.mjs b/tests/adapters.test.mjs index c6d1e44..b355ac2 100644 --- a/tests/adapters.test.mjs +++ b/tests/adapters.test.mjs @@ -180,7 +180,7 @@ test('the adapter never carries the subscriber address', () => { /* --- nothing was rebuilt ------------------------------------------------ */ -test('the 15 artifacts are byte-identical to their committed locks', () => { +test('the 17 artifacts are byte-identical to their committed locks', () => { const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); const locked = {}; for (const m of source.matchAll(/^\s*\['([a-z]+)', (\d+), '([0-9a-f]{64})'\],/gm)) locked[m[1]] = +m[2]; @@ -188,7 +188,7 @@ test('the 15 artifacts are byte-identical to their committed locks', () => { const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(ARTIFACTS[id], 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/build.test.mjs b/tests/build.test.mjs index 7d310b1..be5f647 100644 --- a/tests/build.test.mjs +++ b/tests/build.test.mjs @@ -4,13 +4,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { build, buildLocales, stripModuleSyntax, REQUIRED_HOOKS } from '../tools/build.mjs'; import { TEMPLATES, templateIds, coreTemplateIds, lockedTemplateIds } from '../tools/templates.mjs'; +import { writeIfChanged } from '../tools/write-if-changed.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -986,6 +988,8 @@ const FROZEN_ARTIFACTS = [ ['prismnova', 203226, 'c6eb485fdcf3fb66c5c705eb2f897b176c5fbc0c684b3ff624289714421708cd'], ['terminalnova', 203203, 'afd6ed22a69450915f87fb7dcdfa7b2f8ee47da65f3896ae57ad73d2e00b587a'], ['arcadenova', 203569, 'adb9b1088d8b3d492536cc883f53b806da54a5d2a2d749934fadf611964f450e'], + ['meter', 202571, '194ed2c361529fb0c399c36b724fe0b3053d6896e68d303b4a38429f4a872122'], + ['notebook', 203764, '5dd18cb6d7708e1b3ed50ab3186b84146c7a3b706567f2c6c45cfcb29034bda2'], ]; for (const [id, bytes, sha] of FROZEN_ARTIFACTS) { @@ -1002,14 +1006,88 @@ for (const [id, bytes, sha] of FROZEN_ARTIFACTS) { }); } +/* Meter and Notebook (1.3.0) are the two designs the project's author + contributed, ported onto the shared runtime. Each is held to exactly the + contract of the other fifteen: deterministic, whole, inside the refusal + point, every hook exactly once, and the runtime and locale island shared + byte for byte -- plus the reading order its design fixes, and no CSS + `order`, so the DOM order is the order a screen reader and a phone see. */ +for (const [id, sequence] of [ + ['meter', ['brand-mark', 'state-pill', 'status-heading', 'traffic-trailing', 'traffic-value', 'bar-slot', + 'expiry-value', 'copy-btn', 'qr-btn', 'connect', 'explorer', 'announce-slot', 'support-slot']], + ['notebook', ['brand-mark', 'state-pill', 'live-state', 'copy-btn', 'qr-btn', 'status-heading', 'traffic-value', + 'bar-slot', 'expiry-value', 'connect', 'explorer', 'announce-slot', 'support-slot']], +]) { + const art = build(true, id); + + test(`the ${id} build is deterministic, whole and inside its budget`, () => { + const html = art.html; + const size = Buffer.byteLength(html, 'utf8'); + assert.equal(build(true, id).html, html, 'same sources must produce the same bytes'); + assert.ok(html.startsWith('')); + assert.ok(html.trimEnd().endsWith('')); + assert.equal(html.match(/\/\*__[A-Z][A-Z0-9_]*__\*\//), null); + assert.equal((html.match(/')); + assert.equal(/(^|[;{\s])order\s*:/.test(style), false, 'no CSS order: DOM order is the reading order'); + assert.equal(/url\((?!"data:)/.test(style), false, 'no stylesheet reaches the network'); + assert.equal(/@import/.test(style), false, 'no stylesheet imports another'); + + let previous = -1; + for (const hook of sequence) { + const at = art.html.indexOf(`id="${hook}"`); + assert.ok(at > previous, `#${hook} must follow the region before it in source order`); + previous = at; + } + const mainEnd = art.html.indexOf(''); + for (const hook of ['qr-dialog', 'config-dialog', 'toast']) { + assert.ok(art.html.indexOf(`id="${hook}"`) > mainEnd, `#${hook} must stay outside
`); + } + }); + + test(`the ${id} sources are LF, carry both themes, and keep every literal colour in tokens.css`, () => { + const dir = join(ROOT, 'src', 'templates', id); + for (const f of ['layout.html', 'tokens.css', 'base.css', 'layout.css', 'components.css', 'rtl.css']) { + assert.equal(readFileSync(join(dir, f)).includes(13), false, `${f} must be LF`); + } + const tokens = readFileSync(join(dir, 'tokens.css'), 'utf8'); + assert.match(tokens, /\[data-theme="dark"\]/); + assert.match(tokens, /\[data-theme="light"\]/); + assert.match(tokens, /\/\* row:font-face \*\/[\s\S]*__FONT_BASE64__[\s\S]*\/\* row:font-face end \*\//); + assert.match(tokens, /\[lang="fa"\],\s*\[lang="ar"\]/); + for (const f of ['base.css', 'layout.css', 'components.css', 'rtl.css']) { + const css = readFileSync(join(dir, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + assert.equal(/#[0-9a-fA-F]{3,8}\b(?![^(]*\))/.test(css.replace(/repeating-linear-gradient\([^;]*\)/g, '')), false, + `${f} must read colours from tokens.css`); + } + }); +} + /* --- the frozen set and the template tier --------------------------------- - The frozen set is core-only. The FROZEN_ARTIFACTS table holds eleven of the - fifteen; the other four hold individual locks above. These assertions pin both + The frozen set is core-only. The FROZEN_ARTIFACTS table holds thirteen of the + seventeen; the other four hold individual locks above. These assertions pin both the membership and the size, so a future custom template can never enter the frozen set by accident. */ const INDIVIDUALLY_LOCKED = ['row', 'editorial', 'canvas', 'pulsenova']; -test('the frozen set is exactly the fifteen core templates, and a custom template can never enter it', () => { +test('the frozen set is exactly the seventeen core templates, and a custom template can never enter it', () => { const tableIds = FROZEN_ARTIFACTS.map(([id]) => id); const frozen = [...tableIds, ...INDIVIDUALLY_LOCKED]; @@ -1026,8 +1104,8 @@ test('the frozen set is exactly the fifteen core templates, and a custom templat assert.equal(TEMPLATES[id].locked, true, `${id} is in the frozen set so it must be locked`); } - assert.equal(coreTemplateIds().length, 15, 'exactly fifteen core templates ship in this release'); - assert.equal(lockedTemplateIds().length, 15, 'every core template is locked'); + assert.equal(coreTemplateIds().length, 17, 'exactly seventeen core templates ship in this release'); + assert.equal(lockedTemplateIds().length, 17, 'every core template is locked'); /* Structural exclusion: the table is a literal array and the build reads only `styles` and `emitDataTemplate`, so no registry entry can add itself to the @@ -1038,3 +1116,24 @@ test('the frozen set is exactly the fifteen core templates, and a custom templat } } }); + +/* The build tools write through writeIfChanged, because the suite runs its + files in parallel and two of them rebuild the committed artifacts while + others read them: a truncate-then-write let a reader see half a page. */ +test('build outputs are written only when they change, and never truncated in place', () => { + const dir = mkdtempSync(join(tmpdir(), 'row-wic-')); + try { + const f = join(dir, 'index.html'); + assert.equal(writeIfChanged(f, 'a'), true, 'an absent file is written'); + assert.equal(readFileSync(f, 'utf8'), 'a'); + writeFileSync(join(dir, 'marker'), ''); + const before = statSync(f).mtimeMs; + assert.equal(writeIfChanged(f, 'a'), false, 'identical bytes are not rewritten'); + assert.equal(statSync(f).mtimeMs, before, 'so a concurrent reader is never disturbed'); + assert.equal(writeIfChanged(f, Buffer.from('b')), true, 'changed bytes are written'); + assert.equal(readFileSync(f, 'utf8'), 'b'); + assert.deepEqual(readdirSync(dir).sort(), ['index.html', 'marker'], 'no temporary file is left behind'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/contract.test.mjs b/tests/contract.test.mjs index 5042796..cf96fdb 100644 --- a/tests/contract.test.mjs +++ b/tests/contract.test.mjs @@ -247,7 +247,7 @@ test('validateModel rejects missing, unknown, and wrong-typed fields', () => { /* --- nothing was rebuilt ------------------------------------------------- */ -test('the 15 artifacts are byte-identical to their committed locks', () => { +test('the 17 artifacts are byte-identical to their committed locks', () => { const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); const locked = {}; for (const m of source.matchAll(/^\s*\['([a-z]+)', (\d+), '([0-9a-f]{64})'\],/gm)) locked[m[1]] = +m[2]; @@ -255,7 +255,7 @@ test('the 15 artifacts are byte-identical to their committed locks', () => { const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(ARTIFACTS[id], 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/fixtures/panels/pasarguard/00-showcase.json b/tests/fixtures/panels/pasarguard/00-showcase.json index e927969..a15457c 100644 --- a/tests/fixtures/panels/pasarguard/00-showcase.json +++ b/tests/fixtures/panels/pasarguard/00-showcase.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/01-active-online.json b/tests/fixtures/panels/pasarguard/01-active-online.json index e0a824e..0058ea4 100644 --- a/tests/fixtures/panels/pasarguard/01-active-online.json +++ b/tests/fixtures/panels/pasarguard/01-active-online.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/02-active-offline.json b/tests/fixtures/panels/pasarguard/02-active-offline.json index 3f39bd3..b5f726c 100644 --- a/tests/fixtures/panels/pasarguard/02-active-offline.json +++ b/tests/fixtures/panels/pasarguard/02-active-offline.json @@ -53,7 +53,7 @@ "lastOnline": 1789473600000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/03-disabled.json b/tests/fixtures/panels/pasarguard/03-disabled.json index 3802dd5..bfb5aed 100644 --- a/tests/fixtures/panels/pasarguard/03-disabled.json +++ b/tests/fixtures/panels/pasarguard/03-disabled.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/04-expired.json b/tests/fixtures/panels/pasarguard/04-expired.json index f28e9bc..6d5e0dd 100644 --- a/tests/fixtures/panels/pasarguard/04-expired.json +++ b/tests/fixtures/panels/pasarguard/04-expired.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/05-traffic-exhausted.json b/tests/fixtures/panels/pasarguard/05-traffic-exhausted.json index 139c1ad..f334662 100644 --- a/tests/fixtures/panels/pasarguard/05-traffic-exhausted.json +++ b/tests/fixtures/panels/pasarguard/05-traffic-exhausted.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/06-unlimited-traffic.json b/tests/fixtures/panels/pasarguard/06-unlimited-traffic.json index ee8ab13..f5ce2ef 100644 --- a/tests/fixtures/panels/pasarguard/06-unlimited-traffic.json +++ b/tests/fixtures/panels/pasarguard/06-unlimited-traffic.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/07-never-expires.json b/tests/fixtures/panels/pasarguard/07-never-expires.json index b00079a..b9e3d6a 100644 --- a/tests/fixtures/panels/pasarguard/07-never-expires.json +++ b/tests/fixtures/panels/pasarguard/07-never-expires.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/08-on-hold.json b/tests/fixtures/panels/pasarguard/08-on-hold.json index 71d9f2a..368eed5 100644 --- a/tests/fixtures/panels/pasarguard/08-on-hold.json +++ b/tests/fixtures/panels/pasarguard/08-on-hold.json @@ -1,7 +1,7 @@ { "panel": "pasarguard", "case": "08-on-hold", - "note": "on_hold is a third state with no slot in the contract — recorded, expectation deferred", + "note": "on_hold with a hold duration: enabled, and the clock starts on first connection (expire = -duration)", "source": { "route": "GET /{token}/info", "version": "5.4.1", @@ -42,6 +42,23 @@ } }, "expected": { - "model": null + "model": { + "enabled": true, + "online": true, + "download": 42949672960, + "upload": 0, + "used": 42949672960, + "total": 107374182400, + "expire": -2592000, + "lastOnline": 1789732710000, + "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", + "subJsonUrl": "", + "subClashUrl": "", + "title": "Premium 100 GB", + "supportUrl": "https://t.me/example_support", + "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", + "jalali": false, + "links": [] + } } } diff --git a/tests/fixtures/panels/pasarguard/09-zero-total.json b/tests/fixtures/panels/pasarguard/09-zero-total.json index c0ab6a3..919c4a6 100644 --- a/tests/fixtures/panels/pasarguard/09-zero-total.json +++ b/tests/fixtures/panels/pasarguard/09-zero-total.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/10-no-support.json b/tests/fixtures/panels/pasarguard/10-no-support.json index d81f9b7..005a6f1 100644 --- a/tests/fixtures/panels/pasarguard/10-no-support.json +++ b/tests/fixtures/panels/pasarguard/10-no-support.json @@ -52,7 +52,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/11-no-announce.json b/tests/fixtures/panels/pasarguard/11-no-announce.json index 8c21315..afba181 100644 --- a/tests/fixtures/panels/pasarguard/11-no-announce.json +++ b/tests/fixtures/panels/pasarguard/11-no-announce.json @@ -51,7 +51,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "", diff --git a/tests/fixtures/panels/pasarguard/12-announce-encoded.json b/tests/fixtures/panels/pasarguard/12-announce-encoded.json index d97dfae..3990c83 100644 --- a/tests/fixtures/panels/pasarguard/12-announce-encoded.json +++ b/tests/fixtures/panels/pasarguard/12-announce-encoded.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Line one.\nLine two.", diff --git a/tests/fixtures/panels/pasarguard/13-title-encoded.json b/tests/fixtures/panels/pasarguard/13-title-encoded.json index da65f3b..f967cac 100644 --- a/tests/fixtures/panels/pasarguard/13-title-encoded.json +++ b/tests/fixtures/panels/pasarguard/13-title-encoded.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "گزارش وضعیت", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/14-persian.json b/tests/fixtures/panels/pasarguard/14-persian.json index 75b55e9..55f25e3 100644 --- a/tests/fixtures/panels/pasarguard/14-persian.json +++ b/tests/fixtures/panels/pasarguard/14-persian.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "پرمیوم ۱۰۰ گیگابایت", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/15-hostile-title.json b/tests/fixtures/panels/pasarguard/15-hostile-title.json index 94f4d69..70e011c 100644 --- a/tests/fixtures/panels/pasarguard/15-hostile-title.json +++ b/tests/fixtures/panels/pasarguard/15-hostile-title.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": " & \"quoted\"", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/16-online-at-null.json b/tests/fixtures/panels/pasarguard/16-online-at-null.json index 427b184..c68d14d 100644 --- a/tests/fixtures/panels/pasarguard/16-online-at-null.json +++ b/tests/fixtures/panels/pasarguard/16-online-at-null.json @@ -53,7 +53,7 @@ "lastOnline": null, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/17-ip-present.json b/tests/fixtures/panels/pasarguard/17-ip-present.json index 6c260d6..7cbc374 100644 --- a/tests/fixtures/panels/pasarguard/17-ip-present.json +++ b/tests/fixtures/panels/pasarguard/17-ip-present.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/18-missing-optional.json b/tests/fixtures/panels/pasarguard/18-missing-optional.json index 9577a74..156a9a5 100644 --- a/tests/fixtures/panels/pasarguard/18-missing-optional.json +++ b/tests/fixtures/panels/pasarguard/18-missing-optional.json @@ -49,7 +49,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "", "supportUrl": "", "announce": "", diff --git a/tests/fixtures/panels/pasarguard/19-expire-seconds-vs-ms.json b/tests/fixtures/panels/pasarguard/19-expire-seconds-vs-ms.json index f5f7d5c..dd1dbf6 100644 --- a/tests/fixtures/panels/pasarguard/19-expire-seconds-vs-ms.json +++ b/tests/fixtures/panels/pasarguard/19-expire-seconds-vs-ms.json @@ -53,7 +53,7 @@ "lastOnline": 1789732600000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/20-status-limited.json b/tests/fixtures/panels/pasarguard/20-status-limited.json new file mode 100644 index 0000000..5376585 --- /dev/null +++ b/tests/fixtures/panels/pasarguard/20-status-limited.json @@ -0,0 +1,64 @@ +{ + "panel": "pasarguard", + "case": "20-status-limited", + "note": "the panel reports status limited once used_traffic reaches data_limit; the account stays enabled and the page derives \"limited\" from the figures", + "source": { + "route": "GET /{token}/info", + "version": "5.4.1", + "recorded": "static-read", + "clock": 1789732800 + }, + "native": { + "info": { + "id": 42, + "username": "alice", + "status": "limited", + "used_traffic": 107374182400, + "lifetime_used_traffic": 98784247808, + "data_limit": 107374182400, + "expire": "2026-11-02T12:00:00Z", + "on_hold_expire_duration": null, + "on_hold_timeout": null, + "online_at": "2026-09-18T11:58:30Z", + "created_at": "2026-01-11T12:00:00Z", + "edit_at": null, + "data_limit_reset_strategy": "no_reset", + "hwid_limit": null, + "group_ids": [ + 1 + ], + "ip": null + }, + "headers": { + "subscription-userinfo": "upload=0; download=107374182400; total=107374182400; expire=1793620800", + "profile-web-page-url": "https://sub.example.com/sub/e3b0c44298fc1c14", + "profile-title": "base64:UHJlbWl1bSAxMDAgR0I=", + "support-url": "https://t.me/example_support", + "announce": "base64:U2NoZWR1bGVkIG1haW50ZW5hbmNlIG9uIFN1bmRheSwgMDM6MDAtMDQ6MDAgVVRDLg==", + "announce-url": "https://t.me/example_news", + "profile-update-interval": "12", + "content-disposition": "inline; filename=\"alice\"", + "Cache-Control": "no-store" + } + }, + "expected": { + "model": { + "enabled": true, + "online": true, + "download": 107374182400, + "upload": 0, + "used": 107374182400, + "total": 107374182400, + "expire": 1793620800, + "lastOnline": 1789732710000, + "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", + "subJsonUrl": "", + "subClashUrl": "", + "title": "Premium 100 GB", + "supportUrl": "https://t.me/example_support", + "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", + "jalali": false, + "links": [] + } + } +} diff --git a/tests/fixtures/panels/pasarguard/21-status-expired.json b/tests/fixtures/panels/pasarguard/21-status-expired.json new file mode 100644 index 0000000..49e5a80 --- /dev/null +++ b/tests/fixtures/panels/pasarguard/21-status-expired.json @@ -0,0 +1,64 @@ +{ + "panel": "pasarguard", + "case": "21-status-expired", + "note": "the panel reports status expired once expire passes; the account stays enabled and the page derives \"expired\" from expire", + "source": { + "route": "GET /{token}/info", + "version": "5.4.1", + "recorded": "static-read", + "clock": 1789732800 + }, + "native": { + "info": { + "id": 42, + "username": "alice", + "status": "expired", + "used_traffic": 42949672960, + "lifetime_used_traffic": 98784247808, + "data_limit": 107374182400, + "expire": "2026-09-01T00:00:00Z", + "on_hold_expire_duration": null, + "on_hold_timeout": null, + "online_at": "2026-08-31T23:00:00Z", + "created_at": "2026-01-11T12:00:00Z", + "edit_at": null, + "data_limit_reset_strategy": "no_reset", + "hwid_limit": null, + "group_ids": [ + 1 + ], + "ip": null + }, + "headers": { + "subscription-userinfo": "upload=0; download=42949672960; total=107374182400; expire=1788220800", + "profile-web-page-url": "https://sub.example.com/sub/e3b0c44298fc1c14", + "profile-title": "base64:UHJlbWl1bSAxMDAgR0I=", + "support-url": "https://t.me/example_support", + "announce": "base64:U2NoZWR1bGVkIG1haW50ZW5hbmNlIG9uIFN1bmRheSwgMDM6MDAtMDQ6MDAgVVRDLg==", + "announce-url": "https://t.me/example_news", + "profile-update-interval": "12", + "content-disposition": "inline; filename=\"alice\"", + "Cache-Control": "no-store" + } + }, + "expected": { + "model": { + "enabled": true, + "online": false, + "download": 42949672960, + "upload": 0, + "used": 42949672960, + "total": 107374182400, + "expire": 1788220800, + "lastOnline": 1788217200000, + "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", + "subJsonUrl": "", + "subClashUrl": "", + "title": "Premium 100 GB", + "supportUrl": "https://t.me/example_support", + "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", + "jalali": false, + "links": [] + } + } +} diff --git a/tests/fixtures/panels/pasarguard/22-on-hold-no-duration.json b/tests/fixtures/panels/pasarguard/22-on-hold-no-duration.json new file mode 100644 index 0000000..8e148b6 --- /dev/null +++ b/tests/fixtures/panels/pasarguard/22-on-hold-no-duration.json @@ -0,0 +1,64 @@ +{ + "panel": "pasarguard", + "case": "22-on-hold-no-duration", + "note": "on_hold without a hold duration: enabled, and the expiry is unknown -- never \"never expires\"", + "source": { + "route": "GET /{token}/info", + "version": "5.4.1", + "recorded": "static-read", + "clock": 1789732800 + }, + "native": { + "info": { + "id": 42, + "username": "alice", + "status": "on_hold", + "used_traffic": 42949672960, + "lifetime_used_traffic": 98784247808, + "data_limit": 107374182400, + "expire": null, + "on_hold_expire_duration": null, + "on_hold_timeout": null, + "online_at": "2026-09-18T11:58:30Z", + "created_at": "2026-01-11T12:00:00Z", + "edit_at": null, + "data_limit_reset_strategy": "no_reset", + "hwid_limit": null, + "group_ids": [ + 1 + ], + "ip": null + }, + "headers": { + "subscription-userinfo": "upload=0; download=42949672960; total=107374182400; expire=0", + "profile-web-page-url": "https://sub.example.com/sub/e3b0c44298fc1c14", + "profile-title": "base64:UHJlbWl1bSAxMDAgR0I=", + "support-url": "https://t.me/example_support", + "announce": "base64:U2NoZWR1bGVkIG1haW50ZW5hbmNlIG9uIFN1bmRheSwgMDM6MDAtMDQ6MDAgVVRDLg==", + "announce-url": "https://t.me/example_news", + "profile-update-interval": "12", + "content-disposition": "inline; filename=\"alice\"", + "Cache-Control": "no-store" + } + }, + "expected": { + "model": { + "enabled": true, + "online": true, + "download": 42949672960, + "upload": 0, + "used": 42949672960, + "total": 107374182400, + "expire": null, + "lastOnline": 1789732710000, + "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", + "subJsonUrl": "", + "subClashUrl": "", + "title": "Premium 100 GB", + "supportUrl": "https://t.me/example_support", + "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", + "jalali": false, + "links": [] + } + } +} diff --git a/tests/fixtures/panels/rebecca/08-on-hold.json b/tests/fixtures/panels/rebecca/08-on-hold.json index 0d8634b..4d6f6ea 100644 --- a/tests/fixtures/panels/rebecca/08-on-hold.json +++ b/tests/fixtures/panels/rebecca/08-on-hold.json @@ -1,7 +1,7 @@ { "panel": "rebecca", "case": "08-on-hold", - "note": "on_hold has no slot in the contract — recorded, expectation deferred", + "note": "on_hold: enabled (Rebecca renders it as active); the hold duration is not in the page context, so the expiry is unknown (null)", "source": { "route": "GET /{token}/info", "version": "master", @@ -37,6 +37,23 @@ } }, "expected": { - "model": null + "model": { + "enabled": true, + "online": true, + "download": 42949672960, + "upload": 0, + "used": 42949672960, + "total": 107374182400, + "expire": null, + "lastOnline": 1789732710000, + "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", + "subJsonUrl": "", + "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "title": "Premium 100 GB", + "supportUrl": "https://t.me/example_support", + "announce": "", + "jalali": false, + "links": [] + } } } diff --git a/tests/helpers/panel-hosts.mjs b/tests/helpers/panel-hosts.mjs new file mode 100644 index 0000000..cca9706 --- /dev/null +++ b/tests/helpers/panel-hosts.mjs @@ -0,0 +1,416 @@ +/* Fake PasarGuard and Rebecca hosts, for the installer suites. + * + * Each host is the panel's OFFICIAL layout (docs/design/*-INSTALLER-AUDIT.md), + * relocated under a temporary directory: + * + * PasarGuard opt/pasarguard/{.env,docker-compose.yml}, var/lib/pasarguard/, + * usr/local/bin/pasarguard + * Rebecca opt/rebecca/{.env,docker-compose.yml}, var/lib/rebecca/db.sqlite3, + * usr/local/bin/rebecca + * + * and the adapters are pointed at it through their RT_PG_* / RT_RB_* location + * variables -- the same knobs a non-default APP_NAME would use, so no adapter + * code is bypassed. + * + * WHAT IS DOUBLED, AND WHY. Two external programs the adapters talk to: + * + * docker a shim that keeps "is the panel container running" in a state + * directory, and on `compose up` loads the .env the way Docker + * Compose does (last assignment wins), so `docker exec printenv` + * answers with what a REAL recreated container would hold. It knows + * nothing about Row-Template. + * sqlite3 forwards to Python's sqlite3 module (as the 3X-UI suite does), so + * Rebecca's database is a real SQLite file and every statement the + * adapter runs is executed for real. + * + * Everything else -- the adapters, the transaction engine, the library -- is + * the shipping code. + */ + +import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, readdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { build } from '../../tools/build.mjs'; +import { assembleShell } from '../../tools/shell.mjs'; + +export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +export const sha256 = (b) => createHash('sha256').update(b).digest('hex'); +export const sq = (s) => `'${String(s).split("'").join("'\\''")}'`; + +function workingProgram(candidates, args = ['--version']) { + for (const c of candidates) { + const r = spawnSync(c, args, { encoding: 'utf8' }); + if (!r.error && r.status === 0) return c; + } + throw new Error(`none of these programs is usable: ${candidates.join(', ')}`); +} +export const PYTHON = workingProgram(process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python']); + +/* Run bash with `set -Eeuo pipefail`. PATHS entries are exported as POSIX + paths (cygpath on Windows); ENV entries are exported as given. */ +export function bashRun(lines, { paths = {}, env = {} } = {}) { + const head = Object.entries(paths).map(([k, v]) => + `${k}="$(cygpath -u ${sq(v)} 2>/dev/null || printf '%s' ${sq(v)})"; export ${k}`); + const script = ['set -Eeuo pipefail', + 'unset RT_TEMPLATE RT_RELEASE_URL RT_RELEASE_DIR RT_ASSUME_YES RT_PANEL RT_SMOKE_URL XUI_DB_FOLDER', + ...head, ...lines].join('\n'); + const r = spawnSync('bash', ['-c', script], { + cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...env }, + }); + if (r.error) throw r.error; + return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; +} + +/* The POSIX form of a Windows path, as the shell sees it. */ +export function posix(p) { + const r = spawnSync('bash', ['-c', `cygpath -u ${sq(p)} 2>/dev/null || printf '%s' ${sq(p)}`], { encoding: 'utf8' }); + return (r.stdout || '').trim(); +} + +/* --- a release payload, laid out exactly as tools/make-release.sh does -------- */ + +export function makePayload(dir, { ids = ['row', 'editorial'], panels = ['3xui', 'pasarguard', 'rebecca'], version } = {}) { + const put = (rel, content, mode) => { + const f = join(dir, rel); + mkdirSync(dirname(f), { recursive: true }); + writeFileSync(f, content); + if (mode) chmodSync(f, mode); + }; + const cp = (rel, src, mode) => put(rel, readFileSync(src), mode); + cp('template.html', join(ROOT, 'template', 'index.html')); + put('VERSION', `${version || readFileSync(join(ROOT, 'VERSION'), 'utf8').trim()}\n`); + cp('install.sh', join(ROOT, 'installer', 'install.sh'), 0o755); + cp('bin/row-template', join(ROOT, 'installer', 'bin', 'row-template'), 0o755); + cp('lib/row-template.sh', join(ROOT, 'installer', 'lib', 'row-template.sh')); + cp('lib/transaction.sh', join(ROOT, 'installer', 'lib', 'transaction.sh')); + for (const f of readdirSync(join(ROOT, 'installer', 'panels'))) { + cp(`panels/${f}`, join(ROOT, 'installer', 'panels', f)); + } + for (const id of ids) { + const html = id === 'row' ? readFileSync(join(ROOT, 'template', 'index.html')) : Buffer.from(build(true, id).html); + put(`templates/${id}/template.html`, html); + put(`templates/${id}/template.html.sha256`, `${sha256(html)} templates/${id}/template.html\n`); + } + for (const panel of panels) { + for (const id of ids) { + const html = Buffer.from(assembleShell(panel, id).html); + put(`shells/${panel}/${id}/shell.html`, html); + put(`shells/${panel}/${id}/shell.html.sha256`, `${sha256(html)} shells/${panel}/${id}/shell.html\n`); + } + } + const sums = []; + const walk = (rel) => { + for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) { + const r = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) walk(r); + else if (r !== 'SHA256SUMS') sums.push(`${sha256(readFileSync(join(dir, r)))} ${r}`); + } + }; + walk(''); + put('SHA256SUMS', `${sums.sort().join('\n')}\n`); + return dir; +} + +/* --- the docker shim ------------------------------------------------------------ */ + +const DOCKER_SHIM = `#!/usr/bin/env bash +# Test double for docker. State lives in $RT_TEST_DOCKER; nothing here knows +# about Row-Template. +set -u +st="\${RT_TEST_DOCKER:?}" +printf '%s\\n' "$*" >> "$st/calls" +envget() { # KEY FILE: the last assignment, as compose reads it + awk -v k="$1" ' + { l=$0; sub(/\\r$/,"",l); s=l; sub(/^[ \\t]+/,"",s) + if (s=="" || substr(s,1,1)=="#") next + if (substr(s,1,7)=="export ") s=substr(s,8) + e=index(s,"="); if (!e) next + kk=substr(s,1,e-1); sub(/[ \\t]+$/,"",kk); if (kk!=k) next + v=substr(s,e+1); sub(/^[ \\t]+/,"",v); q=substr(v,1,1) + if (q=="\\"" || q=="\\047") { r=substr(v,2); i=index(r,q); v=(i?substr(r,1,i-1):r) } + else { c=index(v," #"); if (c) v=substr(v,1,c-1); sub(/[ \\t]+$/,"",v) } + val=v; f=1 } + END { if (f) printf "%s", val }' "$2" +} +case "\${1:-}" in + ps) + [ -f "$st/running" ] || exit 0 + img="$(cat "$st/image")" + case "$*" in + *'{{.Names}} {{.Image}}'*) printf '%s %s\\n' "$(cat "$st/name")" "$img" ;; + *) printf '%s\\n' "$img" ;; + esac ;; + compose) + shift; file="" + while [ "$#" -gt 0 ]; do + case "$1" in -f) file="$2"; shift 2 ;; -p) shift 2 ;; *) break ;; esac + done + case "\${1:-}" in + up) + [ -f "$st/fail_up" ] && { echo "compose: injected failure" >&2; exit 1; } + envf="$(dirname "$file")/.env" + : > "$st/container.env" + for k in SUBSCRIPTION_PAGE_TEMPLATE CUSTOM_TEMPLATES_DIRECTORY; do + printf '%s=%s\\n' "$k" "$(envget "$k" "$envf")" >> "$st/container.env" + done + touch "$st/running" + echo up >> "$st/restarts" ;; + *) : ;; + esac ;; + exec) + shift; shift + case "\${1:-}" in + printenv) v="$(grep "^$2=" "$st/container.env" 2>/dev/null | tail -n1 | cut -d= -f2-)"; [ -n "$v" ] || exit 1; printf '%s\\n' "$v" ;; + test) shift; test "$@" ;; + *) exit 1 ;; + esac ;; + image) + # image inspect: the image's recorded config (entrypoint, cmd, workdir), or + # "no such image" when the host has none recorded. + [ "\${2:-}" = inspect ] && [ -f "$st/inspect" ] || { echo "Error: No such image" >&2; exit 1; } + cat "$st/inspect" ;; + version) echo "Docker version 99 (test double)" ;; + *) exit 0 ;; +esac +`; + +/* The image configs `docker image inspect` reports for the two Rebecca + editions, in the adapter's format (entrypoint, cmd, workdir). Rebecca 1.x + (Go) runs rebecca-server; the 0.0.x Python edition -- still what Docker + Hub's rebeccapanel/rebecca:latest is -- runs a script under /code. */ +export const REBECCA_IMAGE = { + go: '["rebecca-server"] null /app', + python: '["/code/scripts/entrypoint.sh"] null /code', +}; + +const SQLITE_SHIM = `#!/usr/bin/env bash +# Test double for the sqlite3 CLI: runs the statement with Python's real +# sqlite3 module. Accepts the adapter's "-cmd .timeout N" prefix. +set -u +while [ "$#" -gt 2 ]; do + case "$1" in -cmd) shift 2 ;; *) shift ;; esac +done +[ -n "\${RT_TEST_SQL_LOG:-}" ] && printf '%s\\n' "$2" >> "$RT_TEST_SQL_LOG" +db="$(cygpath -w "$1" 2>/dev/null || printf '%s' "$1")" +"$RT_TEST_PYTHON" - "$db" "$2" <<'PYEOF' +import sqlite3, sys +con = sqlite3.connect(sys.argv[1]) +try: + cur = con.execute(sys.argv[2]) + rows = cur.fetchall() + con.commit() + for r in rows: + print("|".join("" if v is None else str(v) for v in r)) +finally: + con.close() +PYEOF +`; + +/* The transaction engine locks with flock and refuses to run without it. Git + Bash on Windows has none, so -- exactly as tests/installer-transaction.test.mjs + does -- a minimal double provides flock's contract (an exclusive lock on the + file behind a descriptor, by an atomic mkdir). A host with a real flock uses + the real one. */ +const FLOCK_SHIM = [ + '#!/usr/bin/env bash', + 'mode=""; fd=""', + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -n|-x) mode="n"; shift ;;', + ' -u) mode="u"; shift ;;', + ' -*) shift ;;', + ' *) fd="$1"; shift ;;', + ' esac', + 'done', + '[ -n "$fd" ] || exit 1', + 'target="$(readlink /proc/self/fd/$fd 2>/dev/null)" || exit 1', + '[ -n "$target" ] || exit 1', + 'd="$target.d"', + 'case "$mode" in', + ' u) rmdir "$d" 2>/dev/null; exit 0 ;;', + 'esac', + 'mkdir "$d" 2>/dev/null || exit 1', + 'exit 0', +].join('\n') + '\n'; +const HOST_HAS_FLOCK = spawnSync('bash', ['-c', 'command -v flock'], { encoding: 'utf8' }).status === 0; + +function shims(base, { sqlite = true } = {}) { + const bin = join(base, 'shimbin'); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(bin, 'docker'), DOCKER_SHIM); + chmodSync(join(bin, 'docker'), 0o755); + if (!HOST_HAS_FLOCK) { + writeFileSync(join(bin, 'flock'), FLOCK_SHIM); + chmodSync(join(bin, 'flock'), 0o755); + } + if (sqlite) { + writeFileSync(join(bin, 'sqlite3'), SQLITE_SHIM); + chmodSync(join(bin, 'sqlite3'), 0o755); + } + return bin; +} + +/* --- PasarGuard ------------------------------------------------------------- */ + +export const PG_ENV = [ + 'UVICORN_HOST = "0.0.0.0"', + 'UVICORN_PORT = 8000', + 'SUDO_USERNAME = "admin"', + 'SUDO_PASSWORD = "s3cr3t-Pa55w0rd-do-not-leak"', + '## Custom page templates.', + '# CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates/"', + '# SUBSCRIPTION_PAGE_TEMPLATE = "subscription/index.html"', + 'SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite:////var/lib/pasarguard/db.sqlite3"', + 'JWT_SECRET = "jwt-secret-do-not-leak"', +].join('\n') + '\n'; + +export function pasarguardHost(base, { running = true, env = PG_ENV, compose = true, cli = true, data = true, db = null } = {}) { + const app = join(base, 'opt', 'pasarguard'); + const dataDir = join(base, 'var', 'lib', 'pasarguard'); + const cliPath = join(base, 'usr', 'local', 'bin', 'pasarguard'); + const docker = join(base, 'docker-state'); + mkdirSync(app, { recursive: true }); + mkdirSync(docker, { recursive: true }); + if (data) mkdirSync(dataDir, { recursive: true }); + if (env !== null) writeFileSync(join(app, '.env'), env); + if (compose) { + writeFileSync(join(app, 'docker-compose.yml'), [ + 'services:', ' pasarguard:', ' image: pasarguard/panel:latest', ' restart: always', + ' env_file: .env', ' network_mode: host', ' volumes:', + ` - ${posix(dataDir)}:${posix(dataDir)}`, ''].join('\n')); + } + if (cli) { + mkdirSync(dirname(cliPath), { recursive: true }); + writeFileSync(cliPath, '#!/usr/bin/env bash\n# pasarguard management script (test stand-in)\necho pasarguard "$@"\n'); + chmodSync(cliPath, 0o755); + } + writeFileSync(join(docker, 'image'), 'pasarguard/panel:latest'); + writeFileSync(join(docker, 'name'), 'pasarguard-pasarguard-1'); + if (running) writeFileSync(join(docker, 'running'), ''); + // With `db`, a SQLite database holding PasarGuard's own columns for the two + // settings verify reports on: admins.sub_template, and the subscription JSON + // of the settings row. The .env then points at it, as the official installer's + // absolute sqlite+aiosqlite URL does. + let dbPath = null; + if (db) { + dbPath = join(dataDir, 'db.sqlite3'); + const statements = [ + 'CREATE TABLE admins (id INTEGER PRIMARY KEY, username VARCHAR(34), sub_template VARCHAR(1024))', + 'CREATE TABLE settings (id INTEGER PRIMARY KEY, subscription JSON NOT NULL)', + `INSERT INTO settings (subscription) VALUES ('${JSON.stringify({ allow_browser_config: true, + disable_sub_template: Boolean(db.disable) })}')`, + ]; + (db.admins || []).forEach((t, i) => statements.push( + `INSERT INTO admins (username, sub_template) VALUES ('a${i}', ${t === null ? 'NULL' : `'${t.split("'").join("''")}'`})`)); + const r = spawnSync(PYTHON, ['-c', [ + 'import sqlite3,sys,json', + 'con=sqlite3.connect(sys.argv[1])', + 'for s in json.loads(sys.argv[2]): con.execute(s)', + 'con.commit(); con.close()', + ].join('\n'), dbPath, JSON.stringify(statements)], { encoding: 'utf8' }); + if (r.status !== 0) throw new Error(r.stderr); + writeFileSync(join(app, '.env'), readFileSync(join(app, '.env'), 'utf8').replace( + /^SQLALCHEMY_DATABASE_URL = .*$/m, `SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite:///${posix(dbPath)}"`)); + } + const bin = shims(base, { sqlite: Boolean(db) }); + return { + app, dataDir, cliPath, docker, bin, db: dbPath, envFile: join(app, '.env'), + paths: { RT_PG_APP_DIR: app, RT_PG_DATA_DIR: dataDir, RT_PG_CLI: cliPath, RT_TEST_DOCKER: docker, RT_TEST_BIN: bin }, + }; +} + +/* --- Rebecca -------------------------------------------------------------------- */ + +export function rebeccaHost(base, { running = true, sqlite = true, url, customDir = null, pageTemplate = 'subscription/index.html', + rows = 1, admins = [], compose = true, cli = true, edition = 'go' } = {}) { + const app = join(base, 'opt', 'rebecca'); + const dataDir = join(base, 'var', 'lib', 'rebecca'); + const cliPath = join(base, 'usr', 'local', 'bin', 'rebecca'); + const docker = join(base, 'docker-state'); + const db = join(dataDir, 'db.sqlite3'); + mkdirSync(app, { recursive: true }); + mkdirSync(dataDir, { recursive: true }); + mkdirSync(docker, { recursive: true }); + const dbUrl = url || `sqlite:///${posix(db)}`; + writeFileSync(join(app, '.env'), [ + 'UVICORN_PORT = 8000', + 'SUDO_PASSWORD = "rebecca-secret-do-not-leak"', + `SQLALCHEMY_DATABASE_URL = "${dbUrl}"`, + '', + ].join('\n')); + if (compose) { + writeFileSync(join(app, 'docker-compose.yml'), [ + 'services:', ' rebecca:', ' image: rebeccapanel/rebecca:latest', ' env_file: .env', + ' network_mode: host', ' volumes:', ` - ${posix(dataDir)}:${posix(dataDir)}`, ''].join('\n')); + } + if (cli) { + mkdirSync(dirname(cliPath), { recursive: true }); + writeFileSync(cliPath, '#!/usr/bin/env bash\n# rebecca management script (test stand-in)\necho rebecca "$@"\n'); + chmodSync(cliPath, 0o755); + } + writeFileSync(join(docker, 'image'), 'rebeccapanel/rebecca:latest'); + if (edition) writeFileSync(join(docker, 'inspect'), `${REBECCA_IMAGE[edition]}\n`); + writeFileSync(join(docker, 'name'), 'rebecca-rebecca-1'); + if (running) writeFileSync(join(docker, 'running'), ''); + // Rebecca's own schema for the two tables the adapter reads, plus the + // columns around them that must survive untouched. + const statements = [ + `CREATE TABLE subscription_settings (id INTEGER PRIMARY KEY, subscription_url_prefix VARCHAR(512) NOT NULL DEFAULT '', + subscription_support_url VARCHAR(512) NOT NULL DEFAULT 'https://t.me/', custom_templates_directory VARCHAR(512) NULL, + clash_subscription_template VARCHAR(255) NOT NULL DEFAULT 'clash/default.yml', + subscription_page_template VARCHAR(255) NOT NULL DEFAULT 'subscription/index.html', + home_page_template VARCHAR(255) NOT NULL DEFAULT 'home/index.html')`, + 'CREATE TABLE admins (id INTEGER PRIMARY KEY, username TEXT, subscription_settings TEXT)', + ]; + for (let i = 0; i < rows; i += 1) { + const last = i === rows - 1; + statements.push(`INSERT INTO subscription_settings (subscription_support_url, custom_templates_directory, subscription_page_template) + VALUES ('https://t.me/row${i}', ${last && customDir !== null ? `'${customDir.split("'").join("''")}'` : 'NULL'}, + '${last ? pageTemplate : 'old/page.html'}')`); + } + admins.forEach((a, i) => statements.push(`INSERT INTO admins (username, subscription_settings) VALUES ('a${i}', '${a.split("'").join("''")}')`)); + const r = spawnSync(PYTHON, ['-c', [ + 'import sqlite3,sys,json', + 'con=sqlite3.connect(sys.argv[1])', + 'for s in json.loads(sys.argv[2]): con.execute(s)', + 'con.commit(); con.close()', + ].join('\n'), db, JSON.stringify(statements)], { encoding: 'utf8' }); + if (r.status !== 0) throw new Error(r.stderr); + const bin = shims(base, { sqlite }); + return { + app, dataDir, cliPath, docker, bin, db, envFile: join(app, '.env'), + paths: { RT_RB_APP_DIR: app, RT_RB_DATA_DIR: dataDir, RT_RB_CLI: cliPath, RT_TEST_DOCKER: docker, RT_TEST_BIN: bin }, + }; +} + +/* Read Rebecca's selection row back, from Node, with NULL kept as null. */ +export function rebeccaRow(db) { + const r = spawnSync(PYTHON, ['-c', [ + 'import sqlite3,sys,json', + 'con=sqlite3.connect(sys.argv[1])', + 'rows=con.execute("SELECT id,subscription_page_template,custom_templates_directory,subscription_support_url,clash_subscription_template,home_page_template FROM subscription_settings ORDER BY id").fetchall()', + 'print(json.dumps(rows))', + ].join('\n'), db], { encoding: 'utf8' }); + if (r.status !== 0) throw new Error(r.stderr); + return JSON.parse(r.stdout); +} + +/* The bash preamble that points the library at a fake host: the shims first + on PATH, the host's paths exported, and root checks stubbed (the suite does + not run as root). */ +export const HOST_PREAMBLE = [ + 'export PATH="$RT_TEST_BIN:$PATH"', + `export RT_TEST_PYTHON=${sq(PYTHON)}`, + '. installer/lib/row-template.sh', + 'rt_require_root(){ :; }', + 'rt_detect_xui(){ RT_XUI_BIN=""; RT_XUI_UNIT=""; return 1; }', + 'trap "rt_cleanup" EXIT', +].join('\n'); + +export function copyInto(src, dest) { + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); +} diff --git a/tests/installer-panel-3xui.test.mjs b/tests/installer-panel-3xui.test.mjs index a62c332..d37cf9a 100644 --- a/tests/installer-panel-3xui.test.mjs +++ b/tests/installer-panel-3xui.test.mjs @@ -291,7 +291,7 @@ const svcState = (f) => readFileSync(join(f.work, 'svc'), 'utf8').trim(); /* 1. registration */ /* ------------------------------------------------------------------------ */ -test('3xui is registered and reachable; the other two panels are not', () => { +test('3xui is registered and reachable, as are the two panels added in 1.3.0', () => { const r = sh(` for p in 3xui pasarguard rebecca; do printf 'impl|%s|%s\\n' "$p" "$(rt_panel_impl_for "$p" || true)" @@ -301,19 +301,19 @@ test('3xui is registered and reachable; the other two panels are not', () => { assert.equal(r.code, 0, r.err); const got = new Map(r.out.split('\n').filter(Boolean).map((l) => l.split('|').slice(1))); assert.equal(got.get('3xui'), '3xui', '3xui must resolve to its real implementation'); - assert.equal(got.get('pasarguard'), '', 'pasarguard must resolve to nothing'); - assert.equal(got.get('rebecca'), '', 'rebecca must resolve to nothing'); + assert.equal(got.get('pasarguard'), 'pasarguard', 'pasarguard resolves to its own adapter'); + assert.equal(got.get('rebecca'), 'rebecca', 'rebecca resolves to its own adapter'); }); -test('the shipping adapter is what answers, and no other adapter exists', () => { - assert.equal(existsSync(ADAPTER), true, 'installer/panels/3xui.sh must exist'); - assert.equal(existsSync(join(PANELS_DIR, 'pasarguard.sh')), false); - assert.equal(existsSync(join(PANELS_DIR, 'rebecca.sh')), false); - /* The verbs the dispatcher reaches are the ones the adapter defines. */ - const src = read(ADAPTER); - for (const v of ['detect', 'capabilities', 'backup_state', 'install_template', - 'verify', 'restore_state', 'uninstall_template']) { - assert.match(src, new RegExp(`^rt_panel_3xui_${v}\\(\\)`, 'm'), `adapter must define ${v}`); +test('the shipping adapters are what answer, each defining every frozen verb', () => { + for (const name of ['3xui', 'pasarguard', 'rebecca']) { + const file = join(PANELS_DIR, `${name}.sh`); + assert.equal(existsSync(file), true, `installer/panels/${name}.sh must exist`); + const src = read(file); + for (const v of ['detect', 'capabilities', 'backup_state', 'install_template', + 'verify', 'restore_state', 'uninstall_template']) { + assert.match(src, new RegExp(`^rt_panel_${name}_${v}\\(\\)`, 'm'), `${name} adapter must define ${v}`); + } } }); @@ -907,19 +907,27 @@ test('a post-mutation failure rolls back once, restoring the selection and the s assert.equal(r.code, 0, r.err); const got = new Map(r.out.split('\n').filter(Boolean).map((l) => l.split('|'))); assert.equal(got.get('rc'), '1', 'the transaction must fail'); - /* The engine reports FAILED, not ROLLED_BACK, and that is the CONSERVATIVE, - * contract-correct outcome rather than a defect: it claims ROLLED_BACK only - * after its post-restore static check passes, and for a selection-based - * panel that check asks the INSTALL question ("does the panel serve - * Row-Template?"), to which the honest answer after a rollback is no. The - * engine therefore declines to claim a clean rollback it cannot confirm -- - * it never over-reports. The limitation is recorded in - * INSTALLER-PANEL-3XUI.md; what matters here is what the rollback DID. */ - assert.equal(got.get('state'), 'FAILED', - 'the engine must not claim a clean rollback it cannot confirm'); + /* ROLLED_BACK, and this assertion was CORRECTED in 1.3.0. The engine used to + * report FAILED here, and that was a real defect rather than the + * conservative behaviour it was documented as: the engine ran the FORWARD + * static check after the restore -- "does this panel serve Row-Template?" -- + * and a rollback takes Option A, restoring the panel's PREVIOUS selection, + * so the honest answer is no BY DESIGN. The check therefore failed on every + * correct rollback and a clean rollback was recorded as a failed one. + * + * The engine no longer asks a forward question after a rollback. The + * obligation to verify a restore belongs to the layer that owns the state + * model, and interface.sh already says a panel returns SUCCESS only when + * "the operation completed AND its required verification passed" -- so the + * adapter reads the state back and compares it with the record, and the + * engine trusts that status. The assertions below pin both halves: the + * engine must now CLAIM the clean rollback, and the panel must actually be + * back in its recorded state. */ + assert.equal(got.get('state'), 'ROLLED_BACK', + 'a verified rollback must be recorded as ROLLED_BACK, not FAILED'); assert.equal(got.get('rollback'), '1', 'rollback is attempted exactly once'); - assert.equal(got.get('rollbackfail'), '1', - 'and the engine reports the post-restore check it could not satisfy'); + assert.equal(got.get('rollbackfail'), '0', + 'a rollback whose restore landed must not be reported as a failed rollback'); const map = new Map(dbRows(fx)); assert.equal(map.get('subThemeDir'), '/original', @@ -931,6 +939,48 @@ test('a post-mutation failure rolls back once, restoring the selection and the s } }); +test('a rollback whose restore does not land is reported as a failed rollback', () => { + /* The other half of the correction: dropping the engine's forward check must + * not make the engine blind. A restore that reports FAILURE is still a failed + * rollback, and the engine must say so rather than claiming a clean one. + * + * Both writes must fail, so the injected failure is made to REPEAT. The shim + * only fires while its mark file does not exist; pointing the mark at a path + * inside a directory that does not exist keeps it permanently absent, so + * every UPDATE fails -- the install's (which triggers the rollback) and the + * restore's (which is the failure under test). Matching on UPDATE rather than + * on the value leaves every SELECT alone, so capture and the read-backs still + * see a working database. */ + const fx = makeFixture({ rows: [['subThemeDir', '/original'], ['subPort', '2096']], service: 'active' }); + try { + const r = sh(` + RT_3XUI_SQL_FAIL_ONCE='UPDATE' ; export RT_3XUI_SQL_FAIL_ONCE + RT_3XUI_SQL_FAIL_MARK="$D_WORK/absent-dir/mark" ; export RT_3XUI_SQL_FAIL_MARK + rc=0 + rt_transaction_run 3xui "$RT_ROOT/dist/template.html" >/dev/null 2>"$D_WORK/txn.log" || rc=$? + printf 'rc|%s\\n' "$rc" + printf 'state|%s\\n' "$RT_TXN_STATE" + printf 'rollback|%s\\n' "$(LC_ALL=C grep -cE 'transaction:rollback$' "$D_WORK/txn.log" || true)" + printf 'rollbackfail|%s\\n' "$(LC_ALL=C grep -c 'transaction:rollback-failed' "$D_WORK/txn.log" || true)" + exit 0 + `, { fx }); + assert.equal(r.code, 0, r.err); + const got = new Map(r.out.split('\n').filter(Boolean).map((l) => l.split('|'))); + assert.equal(got.get('rc'), '1', 'the transaction must fail'); + assert.equal(got.get('state'), 'FAILED', + 'a restore that did not land is a failed rollback, and must be reported as one'); + assert.equal(got.get('rollback'), '1', 'rollback is still attempted exactly once'); + assert.equal(got.get('rollbackfail'), '1', + 'and the engine must report it, so the two outcomes stay distinguishable'); + /* No second recovery, and no invented claim: the panel is left as the failed + * attempt left it, and the unrelated row is still untouched. */ + const map = new Map(dbRows(fx)); + assert.equal(map.get('subPort'), '2096', 'unrelated rows are never touched by a failed rollback'); + } finally { + rmSync(fx.base, { recursive: true, force: true }); + } +}); + test('the user-facing format-1 rollback path is untouched by P5A', () => { /* P5A adds an adapter. It does not re-wire the production rollback, which must * keep reading the format-1 namespace only. */ diff --git a/tests/installer-panel-interface.test.mjs b/tests/installer-panel-interface.test.mjs index 74eda89..265b495 100644 --- a/tests/installer-panel-interface.test.mjs +++ b/tests/installer-panel-interface.test.mjs @@ -77,8 +77,9 @@ function sh(body, args = []) { * * TAB separation plus "$@" passes every argument through byte-exactly, * including ones that are empty or contain spaces. */ -function statusTable(rows) { +function statusTable(rows, prelude = '') { const r = sh(` + ${prelude} TAB=$(printf '\t') for row in "$@"; do label="\${row%%"$TAB"*}" @@ -199,11 +200,11 @@ test('a known panel with no implementation is UNAVAILABLE, never SUCCESS and nev established (it performs no detection); SUCCESS would be a fabrication a transaction engine cannot detect. - P5A (2026-09-23) implements 3X-UI, so this sweep runs against the panels - that are STILL unimplemented. It is narrowed, not weakened: the property is - unchanged, and the 3X-UI side of it is asserted positively below rather than - dropped. Requiring a REAL adapter's verbs to return UNAVAILABLE would now be - asserting that working code is broken. */ + Since 1.3.0 every panel in the enum HAS an adapter, so "no implementation" + is produced the way a real host produces it: the adapter did not load (a + payload missing its file leaves exactly this state). The property is + unchanged; only the way the fixture reaches it moved from "no file was ever + written" to "the file is absent from this build". */ const UNIMPLEMENTED = ['pasarguard', 'rebecca']; const verbs = [ ['detect'], @@ -221,7 +222,7 @@ test('a known panel with no implementation is UNAVAILABLE, never SUCCESS and nev rows.push([p + '-' + v[0] + (v[1] ? '-' + v[1] : ''), 'rt_panel_' + v[0], p, ...v.slice(1)].join('\t')); } } - const got = statusTable(rows); + const got = statusTable(rows, 'RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""'); for (const p of UNIMPLEMENTED) { for (const v of verbs) { const k = p + '-' + v[0] + (v[1] ? '-' + v[1] : ''); @@ -231,34 +232,37 @@ test('a known panel with no implementation is UNAVAILABLE, never SUCCESS and nev } }); -test('the implemented panel is driven, and the unimplemented ones are still refused', () => { - /* The positive half of the property above, so the narrowing cannot hide a - regression: 3X-UI must reach its REAL adapter, and the other two must still - stop at the registry. */ - const r = sh(` +test('every implemented panel is driven, and an absent adapter is still refused', () => { + /* The positive half of the property above: each panel reaches its REAL + adapter, and the same panels stop at the registry the moment their adapter + is absent from the build. */ + const probe = ` for p in 3xui pasarguard rebecca; do printf 'impl-%s|%s\\n' "$p" "$(rt_panel_impl_for "$p" || true)" + rc=0; rt_panel_capabilities "$p" >/dev/null 2>&1 || rc=$? + printf 'caps-%s|%s\\n' "$p" "$rc" done - rc=0; rt_panel_capabilities 3xui >/dev/null 2>&1 || rc=$? - printf 'caps-3xui|%s\\n' "$rc" - rc=0; rt_panel_capabilities pasarguard >/dev/null 2>&1 || rc=$? - printf 'caps-pasarguard|%s\\n' "$rc" - rc=0; rt_panel_capabilities rebecca >/dev/null 2>&1 || rc=$? - printf 'caps-rebecca|%s\\n' "$rc" exit 0 - `); - assert.equal(r.code, 0, r.err); - const got = new Map(r.out.split('\n').filter(Boolean).map((l) => { + `; + const parse = (out) => new Map(out.split('\n').filter(Boolean).map((l) => { const i = l.indexOf('|'); return [l.slice(0, i), l.slice(i + 1)]; })); - assert.equal(got.get('impl-3xui'), '3xui', '3xui must resolve to its real implementation'); - assert.equal(got.get('impl-pasarguard'), '', 'pasarguard must resolve to nothing'); - assert.equal(got.get('impl-rebecca'), '', 'rebecca must resolve to nothing'); - /* 3X-UI reports its real capabilities; the other two never reach an adapter. */ - assert.equal(got.get('caps-3xui'), '0'); - assert.equal(got.get('caps-pasarguard'), '2'); - assert.equal(got.get('caps-rebecca'), '2'); + const r = sh(probe); + assert.equal(r.code, 0, r.err); + const got = parse(r.out); + for (const p of ['3xui', 'pasarguard', 'rebecca']) { + assert.equal(got.get(`impl-${p}`), p, `${p} must resolve to its real implementation`); + assert.equal(got.get(`caps-${p}`), '0', `${p} reports its real capabilities`); + } + const absent = sh('RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""\n' + probe); + assert.equal(absent.code, 0, absent.err); + const gone = parse(absent.out); + assert.equal(gone.get('impl-3xui'), '3xui', 'a loaded adapter is unaffected'); + for (const p of ['pasarguard', 'rebecca']) { + assert.equal(gone.get(`impl-${p}`), '', `${p} without its adapter must resolve to nothing`); + assert.equal(gone.get(`caps-${p}`), '2', `${p} without its adapter never reaches one`); + } }); test('a malformed invocation is FAILURE, distinct from UNAVAILABLE', () => { @@ -421,24 +425,35 @@ test('capability output is deterministic and machine-readable', () => { `sh()` trims stdout, so the exit status is captured separately rather than echoed into the same stream — mixing the two made an earlier version of this case assert against its own probe output instead of the interface's. */ - const a = sh('rt_panel_capabilities pasarguard 2>/dev/null || true'); - const b = sh('rt_panel_capabilities pasarguard 2>/dev/null || true'); + const a = sh('RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""; rt_panel_capabilities pasarguard 2>/dev/null || true'); + const b = sh('RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""; rt_panel_capabilities pasarguard 2>/dev/null || true'); assert.equal(a.out, b.out, 'two runs must produce identical bytes'); - assert.equal(a.out, '', 'an unimplemented panel must print NOTHING on stdout'); - const st = statusTable([['caps', 'rt_panel_capabilities', 'pasarguard'].join('\t')]); + assert.equal(a.out, '', 'a panel without its adapter must print NOTHING on stdout'); + const st = statusTable([['caps', 'rt_panel_capabilities', 'pasarguard'].join('\t')], 'RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""'); assert.equal(st.caps, 2, 'and must report UNAVAILABLE'); /* An IMPLEMENTED panel prints exactly its tokens and nothing else: no prose, no header, no decoration, byte-identical across runs. Narrowed to a panel that is still unimplemented above rather than weakened -- the machine-channel property is asserted for BOTH kinds of panel. */ - const c = sh('rt_panel_capabilities 3xui 2>/dev/null || true'); - const d = sh('rt_panel_capabilities 3xui 2>/dev/null || true'); - assert.equal(c.out, d.out, 'an implemented panel must be deterministic too'); - for (const line of c.out.split('\n').filter(Boolean)) { - assert.match(line, /^[a-z_]+$/, `only bare tokens may reach the machine channel: ${line}`); + const EXPECTED = { + '3xui': 'db_activation selection_read selection_write service_control static_verify', + pasarguard: 'env_activation file_placement live_verify selection_read selection_write service_control static_verify', + rebecca: 'db_activation file_placement selection_read selection_write static_verify', + }; + for (const [panel, tokens] of Object.entries(EXPECTED)) { + const c = sh(`rt_panel_capabilities ${panel} 2>/dev/null || true`); + const d = sh(`rt_panel_capabilities ${panel} 2>/dev/null || true`); + assert.equal(c.out, d.out, `${panel}: an implemented panel must be deterministic too`); + const lines = c.out.split('\n').filter(Boolean); + for (const line of lines) { + assert.match(line, /^[a-z_]+$/, `only bare tokens may reach the machine channel: ${line}`); + } + assert.deepEqual(lines, [...lines].sort(), `${panel}: tokens in LC_ALL=C order`); + assert.equal(lines.join(' '), tokens, `${panel}: exactly the capabilities its code implements`); } /* No prose may ever reach the machine channel from any verb. */ const r = sh(` + RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED="" for v in detect capabilities backup_state verify restore_state uninstall_template; do out="$(rt_panel_$v pasarguard 2>/dev/null || true)" [ -n "$out" ] && echo "STDOUT-FROM:$v[$out]" @@ -525,28 +540,28 @@ test('the interface never evals, sources or reconstructs a command', () => { assert.ok(libSources.length >= 1, 'the library must source the interface explicitly'); }); -test('the panels directory holds the contract plus exactly the authorised adapter', () => { +test('the panels directory holds the contract plus exactly the authorised adapters', () => { /* A stub that pretends to be an implementation is how a temporary shim - becomes permanent, and no phase may inherit one. The claim is therefore - kept and made PRECISE rather than dropped: the directory is the two frozen - contract files plus exactly the adapters a phase has been authorised to - add -- one, as of P5A (2026-09-23). A second file appearing here is still a - failure, and the two panels with no adapter are still asserted absent. */ + becomes permanent, and no phase may inherit one. The claim is kept and + made PRECISE: the directory is the two frozen contract files plus exactly + the adapters a release has authorised -- 3xui (P5A), pasarguard and + rebecca (1.3.0). A further file appearing here is still a failure. */ const r = sh('ls installer/panels/'); assert.equal(r.code, 0, r.err); const files = r.out.split('\n').map((s) => s.trim()).filter(Boolean).sort(); - assert.deepEqual(files, ['3xui.sh', 'index.sh', 'interface.sh'], - 'expected the two contract files plus the authorised 3xui adapter, found: ' + files.join(', ')); - for (const f of ['pasarguard.sh', 'rebecca.sh']) { - assert.equal(files.includes(f), false, f + ' must not exist: no adapter is authorised for it'); - } - /* And the registry names exactly one panel-specific implementation -- the - authorised one. The lookup itself is still the single decision point. */ + assert.deepEqual(files, ['3xui.sh', 'index.sh', 'interface.sh', 'pasarguard.sh', 'rebecca.sh'], + 'expected the two contract files plus the three authorised adapters, found: ' + files.join(', ')); + /* And the registry reaches exactly those three implementations through its + one lookup. */ const idx = codeOf(read(INDEX)); assert.match(idx, /rt_panel_impl_for/, 'the registry must expose one lookup'); - assert.equal(/rt_panel_(pasarguard|rebecca)_/.test(idx), false, - 'the registry must name no implementation for an unimplemented panel'); - assert.match(idx, /rt_panel_3xui_/, 'the registry must reach the authorised adapter'); + for (const p of ['3xui', 'pasarguard', 'rebecca']) { + assert.match(idx, new RegExp(`rt_panel_${p}_`), `the registry must reach the ${p} adapter`); + } + /* every dispatch arm for `detect`: exactly one per adapter, and no other */ + assert.deepEqual([...new Set(idx.match(/rt_panel_[a-z0-9]+_detect "\$panel"/g) || [])].sort(), + ['rt_panel_3xui_detect "$panel"', 'rt_panel_pasarguard_detect "$panel"', 'rt_panel_rebecca_detect "$panel"'], + 'and no other adapter'); }); test('no activation exists in the interface', () => { @@ -592,12 +607,12 @@ test('verify supports static and live, and rejects any other mode', () => { ['bogus', 'rt_panel_verify', '3xui', 'bogus'].join('\t'), ['upper', 'rt_panel_verify', '3xui', 'STATIC'].join('\t'), ['none', 'rt_panel_verify', '3xui'].join('\t'), - ]); + ], 'RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""'); /* static and live are accepted modes: they reach the implementation and get UNAVAILABLE (2), not FAILURE (1). A mode rejected by validation is 1. - Acceptance is asserted on a panel that is still UNIMPLEMENTED, because a - real adapter legitimately answers a valid mode with its own status rather - than UNAVAILABLE. Rejection is asserted on the IMPLEMENTED panel, which is + Acceptance is asserted on a panel whose adapter is ABSENT from the build, + because a real adapter legitimately answers a valid mode with its own + status rather than UNAVAILABLE. Rejection is asserted on the IMPLEMENTED panel, which is the stronger case: the real adapter must still refuse a bad mode. */ assert.equal(got.static, 2, 'static must be a valid mode (unimplemented panel => 2)'); assert.equal(got.live, 2, 'live must be a valid mode (unimplemented panel => 2)'); @@ -620,7 +635,7 @@ test('RT_PANEL_STAGE is the only structured backup-state channel', () => { /* It is the P2 variable, referenced not redeclared. */ assert.equal(/^RT_PANEL_STAGE=/m.test(iface), false, 'RT_PANEL_STAGE must be referenced, not redeclared'); - assert.match(read(LIB), /^RT_PANEL_STAGE=/m, 'the library owns the definition'); + assert.match(read(LIB), /^\s*RT_PANEL_STAGE=/m, 'the library owns the definition'); /* The interface must not invent a second staging location. */ const all = codeOf(iface); assert.equal(/(RT_ROOT\/[a-z.]*stage|RT_PANEL_TMP|RT_PANEL_WORK|\.panel-work)/.test(all), diff --git a/tests/installer-panel-pasarguard.test.mjs b/tests/installer-panel-pasarguard.test.mjs new file mode 100644 index 0000000..1a76fe3 --- /dev/null +++ b/tests/installer-panel-pasarguard.test.mjs @@ -0,0 +1,408 @@ +/* The PasarGuard panel adapter (1.3.0) and the installer flows that drive it. + * + * Every case runs the SHIPPING code -- installer/panels/pasarguard.sh behind + * the frozen interface, the transaction engine, the library -- against a fake + * host laid out like the official PasarGuard installer's (tests/helpers/ + * panel-hosts.mjs). Only `docker` is doubled, by a shim that loads .env the way + * Docker Compose does, so "the running container uses our page" is a real + * question with a real answer. + * + * What is proven, in order: detection needs two signals; .env is read the way + * dotenv reads it; activation places exactly one file and appends exactly one + * block; everything is restored BYTE-EXACT (uninstall and rollback alike); the + * panel is restarted only when it was running and only when its environment + * changed; operator files and lines are never touched; secrets never leave + * .env; and the full install -> verify -> branding -> design switch -> rollback + * -> uninstall life cycle works end to end. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + bashRun, makePayload, pasarguardHost, HOST_PREAMBLE, sha256, PG_ENV, +} from './helpers/panel-hosts.mjs'; + +const PAYLOAD_DIR = mkdtempSync(join(tmpdir(), 'row-pg-payload-')); +const PAYLOAD = makePayload(PAYLOAD_DIR, { ids: ['row', 'editorial'] }); +process.on('exit', () => rmSync(PAYLOAD_DIR, { recursive: true, force: true })); + +/* A Row-Template install on a PasarGuard host, up to a generated page -- the + state activation starts from. */ +const SETUP = [ + 'RT_ACTIVE_PANEL=pasarguard', + 'rt_layout_ensure', + 'rt_repair_template_store "$PAYLOAD" >/dev/null || [ $? -eq 2 ]', + 'rt_set_dist "$RT_TEMPLATE_STORE/row/template.html"', + 'rt_config_write "Test VPN" "" "" ""', + 'rt_activate', +].join('\n'); + +function withHost(opts, fn) { + const base = mkdtempSync(join(tmpdir(), 'row-pg-')); + try { + const host = pasarguardHost(base, opts); + const rt = join(base, 'rt'); + const run = (lines) => bashRun([HOST_PREAMBLE, ...[].concat(lines)], + { paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD } }); + return fn({ base, host, rt, run }); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +const page = (host, root = join(host.dataDir, 'templates')) => join(root, 'row-template', 'index.html'); +const restarts = (host) => (existsSync(join(host.docker, 'restarts')) + ? readFileSync(join(host.docker, 'restarts'), 'utf8').split('\n').filter(Boolean).length : 0); +const containerEnv = (host) => (existsSync(join(host.docker, 'container.env')) + ? readFileSync(join(host.docker, 'container.env'), 'utf8') : ''); + +/* --- detection ----------------------------------------------------------------- */ + +test('detection needs two independent signals', () => { + const cases = [ + [{}, 0, 'the official layout'], + [{ compose: false, cli: false }, 0, '.env and the data directory'], + [{ compose: false, cli: false, data: false }, 1, '.env alone is one signal: FAILURE, not a guess'], + [{ env: null, compose: false, cli: false }, 1, 'the data directory alone'], + [{ env: null, compose: false, cli: false, data: false }, 3, 'nothing: NOT_APPLICABLE'], + ]; + for (const [opts, want, label] of cases) { + withHost(opts, ({ run }) => { + const r = run('rc=0; rt_panel_detect pasarguard || rc=$?; echo "rc=$rc"'); + assert.match(r.out, new RegExp(`rc=${want}`), `${label}\n${r.err}`); + }); + } +}); + +test('capabilities are exactly what the adapter implements', () => { + withHost({}, ({ run }) => { + const r = run('rt_panel_capabilities pasarguard'); + assert.equal(r.code, 0, r.err); + assert.equal(r.out, ['env_activation', 'file_placement', 'live_verify', 'selection_read', + 'selection_write', 'service_control', 'static_verify'].join('\n')); + }); +}); + +/* --- .env, read as dotenv reads it ------------------------------------------ */ + +test('rt_dotenv_get reads a value the way dotenv does, and never evaluates it', () => { + withHost({}, ({ base, run }) => { + const f = join(base, 'sample.env'); + writeFileSync(f, [ + 'A=plain', 'B = "spaced and quoted"', "C='single'", 'export D=exported', + 'E=unquoted # a comment', 'F="kept # inside quotes"', 'G=first', 'G=last', + '# H=commented', 'I=', 'J=$(touch /tmp/row-pwned)', 'K=a\r', '', + ].join('\n')); + const r = run([ + `F=${JSON.stringify(f.split('\\').join('/'))}; F="$(cygpath -u "$F" 2>/dev/null || printf '%s' "$F")"`, + 'for k in A B C D E F G H I J K Z; do rc=0; v="$(rt_dotenv_get "$F" "$k")" || rc=$?; printf "%s=[%s] rc=%s\\n" "$k" "$v" "$rc"; done', + ]); + assert.equal(r.code, 0, r.err); + for (const line of ['A=[plain] rc=0', 'B=[spaced and quoted] rc=0', 'C=[single] rc=0', 'D=[exported] rc=0', + 'E=[unquoted] rc=0', 'F=[kept # inside quotes] rc=0', 'G=[last] rc=0', 'H=[] rc=3', 'I=[] rc=0', + 'J=[$(touch /tmp/row-pwned)] rc=0', 'K=[a] rc=0', 'Z=[] rc=3']) { + assert.ok(r.out.includes(line), `expected ${line}\n${r.out}`); + } + }); +}); + +/* --- activation, and its exact inverse --------------------------------------- */ + +test('activation places one page, appends one block, restarts once; uninstall restores .env byte for byte', () => { + withHost({}, ({ host, run }) => { + const before = readFileSync(host.envFile); + const r = run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>&1 | grep -v "^transaction:" || true', + 'echo "state=$RT_TXN_STATE"', + 'rc=0; rt_panel_verify pasarguard static || rc=$?; echo "static=$rc"', + 'rc=0; rt_panel_verify pasarguard live || rc=$?; echo "live=$rc"']); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /static=0/, r.err); + assert.match(r.out, /live=0/, `the recreated container reads the new page\n${r.err}`); + + const env = readFileSync(host.envFile, 'utf8'); + assert.ok(env.startsWith(before.toString()), 'every original byte is kept, in place'); + const block = env.slice(before.length); + assert.match(block, /^# >>> row-template \(managed by Row-Template; do not edit\) nl=0 >>>\n/); + assert.match(block, new RegExp(`CUSTOM_TEMPLATES_DIRECTORY = ".*/var/lib/pasarguard/templates"\\n`)); + assert.match(block, /SUBSCRIPTION_PAGE_TEMPLATE = "row-template\/index.html"\n# <<< row-template <<<\n$/); + assert.equal(existsSync(page(host)), true, 'the page is placed'); + assert.equal(readdirSync(join(host.dataDir, 'templates')).join(','), 'row-template', 'and nothing else'); + assert.equal(restarts(host), 1, 'the running panel is restarted exactly once'); + assert.match(containerEnv(host), /SUBSCRIPTION_PAGE_TEMPLATE=row-template\/index.html/); + + const u = run(['RT_ACTIVE_PANEL=pasarguard', 'rt_panel_uninstall_template pasarguard; echo "rc=$?"']); + assert.match(u.out, /rc=0/, u.err); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before), '.env is back byte for byte'); + assert.equal(existsSync(join(host.dataDir, 'templates')), false, 'the directories Row-Template created are gone'); + assert.equal(restarts(host), 2, 'and the panel restarted onto its own page'); + assert.doesNotMatch(containerEnv(host), /row-template/); + }); +}); + +test('an operator CUSTOM_TEMPLATES_DIRECTORY is used, not overridden', () => { + withHost({}, ({ host, run }) => { + const own = join(host.dataDir, 'my-templates'); + mkdirSync(join(own, 'subscription'), { recursive: true }); + writeFileSync(join(own, 'subscription', 'index.html'), 'operator page'); + const ownPosix = run(`printf '%s' "$(cygpath -u '${own.split('\\').join('/')}' 2>/dev/null || printf '%s' '${own.split('\\').join('/')}')"`).out; + writeFileSync(host.envFile, `${PG_ENV}CUSTOM_TEMPLATES_DIRECTORY = "${ownPosix}/"\n`); + const before = readFileSync(host.envFile); + const r = run([SETUP, 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null || rc=$?; echo "rc=$rc"']); + assert.match(r.out, /rc=0/, r.err); + const block = readFileSync(host.envFile, 'utf8').slice(before.length); + assert.doesNotMatch(block, /CUSTOM_TEMPLATES_DIRECTORY/, 'the operator directory is not overridden'); + assert.equal(existsSync(page(host, own)), true, 'the page goes into the operator directory'); + assert.equal(readFileSync(join(own, 'subscription', 'index.html'), 'utf8'), 'operator page', 'their page is untouched'); + const u = run('rt_panel_uninstall_template pasarguard; echo "rc=$?"'); + assert.match(u.out, /rc=0/, u.err); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before)); + assert.equal(existsSync(join(own, 'row-template')), false, 'our directory is removed'); + assert.equal(existsSync(own), true, 'the operator directory is not'); + }); +}); + +test('a templates directory the container cannot share with the host is refused before anything changes', () => { + withHost({}, ({ host, run }) => { + writeFileSync(host.envFile, `${PG_ENV}CUSTOM_TEMPLATES_DIRECTORY = "/srv/elsewhere"\n`); + const before = readFileSync(host.envFile); + const r = run([SETUP, 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" || rc=$?; echo "rc=$rc mutated=$RT_TXN_MUTATED"']); + assert.match(r.out, /rc=1 mutated=0/, r.err); + assert.match(r.err, /outside .*pasarguard, the directory the container shares with the host/); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before), '.env is untouched'); + assert.equal(restarts(host), 0); + }); +}); + +test('a page that is not Row-Template\'s is never overwritten, and the failed activation is rolled back', () => { + withHost({}, ({ host, run }) => { + const before = readFileSync(host.envFile); + mkdirSync(join(host.dataDir, 'templates', 'row-template'), { recursive: true }); + writeFileSync(page(host), '

someone else

'); + const r = run([SETUP, 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" || rc=$?; echo "rc=$rc state=$RT_TXN_STATE"']); + assert.match(r.out, /rc=1/, r.err); + assert.match(r.err, /exists and is not Row-Template's/); + assert.equal(readFileSync(page(host), 'utf8'), '

someone else

', 'the operator file is untouched'); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before)); + }); +}); + +test('a failure after the change is made is rolled back to the exact previous state', () => { + withHost({}, ({ host, run }) => { + writeFileSync(join(host.docker, 'fail_up'), ''); + const before = readFileSync(host.envFile); + const r = run([SETUP, 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" || rc=$?; echo "rc=$rc state=$RT_TXN_STATE"']); + assert.match(r.out, /rc=1 state=(ROLLED_BACK|FAILED)/, r.err); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before), '.env is restored byte for byte'); + assert.equal(existsSync(join(host.dataDir, 'templates')), false, 'the page and its directories are removed'); + }); +}); + +test('a stopped panel is not started; its next start picks the change up', () => { + withHost({ running: false }, ({ host, run }) => { + const r = run([SETUP, 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null || rc=$?; echo "rc=$rc"', + 'rc=0; rt_panel_verify pasarguard live || rc=$?; echo "live=$rc"']); + assert.match(r.out, /rc=0/, r.err); + assert.match(r.out, /live=2/, 'live verification is UNAVAILABLE, not a failure'); + assert.equal(restarts(host), 0, 'never started'); + assert.equal(existsSync(join(host.docker, 'running')), false); + }); +}); + +test('re-applying is idempotent: no second block, no second restart', () => { + withHost({}, ({ host, run }) => { + const r = run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null', + 'rt_panel_install_template pasarguard "$RT_LIVE"; echo "rc=$?"']); + assert.match(r.out, /rc=0/, r.err); + assert.equal((readFileSync(host.envFile, 'utf8').match(/# >>> row-template/g) || []).length, 1); + assert.equal(restarts(host), 1); + }); +}); + +test('a .env without a final newline is restored without one', () => { + withHost({ env: PG_ENV.trimEnd() }, ({ host, run }) => { + const before = readFileSync(host.envFile); + run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null']); + assert.match(readFileSync(host.envFile, 'utf8'), /nl=1 >>>/, 'the block records the newline it added'); + run('rt_panel_uninstall_template pasarguard'); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before)); + }); +}); + +test('a line the operator adds after the block survives uninstall', () => { + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null']); + writeFileSync(host.envFile, `${readFileSync(host.envFile, 'utf8')}DEBUG = true\n`); + run('rt_panel_uninstall_template pasarguard'); + assert.equal(readFileSync(host.envFile, 'utf8'), `${PG_ENV}DEBUG = true\n`); + }); +}); + +test('a damaged block is refused rather than interpreted', () => { + withHost({}, ({ host, run }) => { + writeFileSync(host.envFile, `${PG_ENV}# >>> row-template (managed by Row-Template; do not edit) nl=0 >>>\nX=1\n`); + const before = readFileSync(host.envFile); + const r = run([SETUP, 'rc=0; rt_panel_uninstall_template pasarguard || rc=$?; echo "rc=$rc"', + 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" || rc=$?; echo "txn=$rc"']); + assert.match(r.out, /rc=1/); + assert.match(r.out, /txn=1/); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before)); + }); +}); + +test('restore refuses a record that lists a file this adapter never places', () => { + withHost({}, ({ run }) => { + const r = run([SETUP, + 'rt_transaction_stage_reset', 'rt_panel_backup_state pasarguard', + 'printf "../../etc/passwd\\n" > "$RT_PANEL_STAGE/pasarguard/files"', + 'snap="$(rt_backup_create v2 pasarguard 2>/dev/null)" || { echo "snapshot-refused"; exit 0; }', + 'rc=0; rt_panel_restore_state pasarguard "$snap" || rc=$?; echo "rc=$rc"']); + assert.match(r.out, /snapshot-refused|rc=1/, r.err); + }); +}); + +test('verify fails a placed page that was changed, and a selection that was removed', () => { + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null']); + const good = readFileSync(page(host)); + writeFileSync(page(host), good.toString().replace('Test VPN', 'Tampered')); + let r = run('rc=0; rt_panel_verify pasarguard static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/); + assert.match(r.err, /placed page differs/); + writeFileSync(page(host), good); + const env = readFileSync(host.envFile, 'utf8'); + writeFileSync(host.envFile, env.replace(/SUBSCRIPTION_PAGE_TEMPLATE = "row-template\/index.html"/, 'SUBSCRIPTION_PAGE_TEMPLATE = "subscription/index.html"')); + r = run('rc=0; rt_panel_verify pasarguard static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/); + assert.match(r.err, /does not select the Row-Template page/); + }); +}); + +/* Two settings in PasarGuard's database outrank the selected page: an admin's + own sub_template, and disable_sub_template (app/operation/subscription.py). + Row-Template never changes them; verify names them, never fails on them, and + reads the database without writing a byte. */ +test('verify names the database settings that outrank the page, and never writes the database', () => { + withHost({ db: { admins: [null, 'custom/admin.html', ''], disable: true } }, ({ host, run }) => { + run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null']); + const before = sha256(readFileSync(host.db)); + const r = run('rc=0; rt_panel_verify pasarguard static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=0/, 'advice, never a failure'); + assert.match(r.err, /1 admin\(s\) set their own subscription page \(sub_template\)/); + assert.match(r.err, /'disable subscription template' setting is on/); + assert.equal(sha256(readFileSync(host.db)), before, 'the database is read, never written'); + assert.equal(r.out.includes(host.db) || r.err.includes('sqlite+aiosqlite'), false, 'the database URL is never printed'); + }); + withHost({ db: { admins: [null, ''], disable: false } }, ({ run }) => { + run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null']); + const r = run('rc=0; rt_panel_verify pasarguard static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=0/); + assert.equal(/sub_template|disable subscription template/.test(r.err), false, 'nothing to say when nothing overrides'); + }); + // A server database (its URL carries a password) is never read, and never printed. + withHost({ env: PG_ENV.replace(/^SQLALCHEMY_DATABASE_URL = .*$/m, + 'SQLALCHEMY_DATABASE_URL = "postgresql+asyncpg://pg:db-pass-do-not-leak@127.0.0.1:5432/pasarguard"') }, ({ run }) => { + const r = run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null', + 'rc=0; rt_panel_verify pasarguard static || rc=$?; echo "rc=$rc"']); + assert.match(r.out, /rc=0/); + assert.equal((r.out + r.err).includes('db-pass-do-not-leak'), false); + }); +}); + +test('secrets in .env never reach output, logs or snapshots', () => { + withHost({}, ({ rt, run }) => { + const r = run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE"', 'rt_panel_verify pasarguard static', + 'rt_panel_verify pasarguard live', 'rt_panel_uninstall_template pasarguard']); + for (const secret of ['s3cr3t-Pa55w0rd-do-not-leak', 'jwt-secret-do-not-leak']) { + assert.equal(r.out.includes(secret) || r.err.includes(secret), false, 'not in output'); + const snaps = join(rt, 'backups.v2'); + const walk = (d) => readdirSync(d, { withFileTypes: true }).flatMap((e) => + (e.isDirectory() ? walk(join(d, e.name)) : [join(d, e.name)])); + for (const f of existsSync(snaps) ? walk(snaps) : []) { + assert.equal(readFileSync(f).includes(secret), false, `not in ${f}`); + } + } + }); +}); + +/* --- the artifact must fit the panel ---------------------------------------- */ + +test('a 3X-UI artifact, or a shell from before 1.3.0, is refused on PasarGuard', () => { + withHost({}, ({ base, run }) => { + const old = join(base, 'old-shell.html'); + const shell = readFileSync(join(PAYLOAD, 'shells', 'pasarguard', 'row', 'shell.html'), 'utf8'); + // a 1.2.x shell: the same layout without the context prelude and escaping + writeFileSync(old, shell.replace(/\{#-[\s\S]*?-#\}\n/, '').replace(/\{%-? ?set [^%]*%\}\n?/g, '') + .replace('{%- autoescape true -%}\n', '').replace('{%- endautoescape %}', '')); + const r = run([SETUP, + 'rc=0; rt_set_dist "$PAYLOAD/template.html" 2>/dev/null || rc=$?; echo "xui=$rc"', + `O=${JSON.stringify(old.split('\\').join('/'))}; O="$(cygpath -u "$O" 2>/dev/null || printf '%s' "$O")"`, + 'rc=0; rt_set_dist "$O" 2>/dev/null || rc=$?; echo "old=$rc"', + 'rc=0; rt_set_dist "$RT_TEMPLATE_STORE/editorial/template.html" || rc=$?; echo "ok=$rc"']); + assert.match(r.out, /xui=1/, 'the 3X-UI artifact is refused'); + assert.match(r.out, /old=1/, 'a shell without the prelude and escaping is refused'); + assert.match(r.out, /ok=0/, 'a 1.3.0 PasarGuard page is accepted'); + }); +}); + +test('a backup made for another panel is never restored onto PasarGuard', () => { + withHost({}, ({ run }) => { + const r = run([SETUP, + 'B="$(rt_backup_create)"', + 'sed -i "s/^panel=pasarguard$/panel=3xui/" "$B/meta"', + 'rc=0; rt_restore_from_backup "$B" 2>/dev/null || rc=$?; echo "rc=$rc"', + 'B2="$(rt_backup_create)"; grep -c "^panel=pasarguard$" "$B2/meta"']); + assert.match(r.out, /rc=1/, 'refused'); + assert.match(r.out, /\n1$/, 'and a PasarGuard backup records its panel'); + }); +}); + +/* --- the whole life cycle ---------------------------------------------------- */ + +test('install, verify, rebrand, switch design, roll back and uninstall on a PasarGuard host', () => { + withHost({}, ({ base, host, rt, run }) => { + const envBefore = readFileSync(host.envFile); + const ENV = 'export RT_ASSUME_NONINTERACTIVE=1 RT_SERVICE_NAME="Aurora Net" RT_SUPPORT_URL="" RT_LOGO_REMOVE=1'; + let r = run([ENV, 'rt_cmd_install "$PAYLOAD" rmSync(PAYLOAD_DIR, { recursive: true, force: true })); + +const SETUP = [ + 'RT_ACTIVE_PANEL=rebecca', + 'rt_layout_ensure', + 'rt_repair_template_store "$PAYLOAD" >/dev/null || [ $? -eq 2 ]', + 'rt_set_dist "$RT_TEMPLATE_STORE/row/template.html"', + 'rt_config_write "Test VPN" "" "" ""', + 'rt_activate', +].join('\n'); + +function withHost(opts, fn) { + const base = mkdtempSync(join(tmpdir(), 'row-rb-')); + try { + const host = rebeccaHost(base, opts); + const rt = join(base, 'rt'); + const run = (lines, env = {}) => bashRun([HOST_PREAMBLE, ...[].concat(lines)], + { paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD }, env }); + return fn({ base, host, rt, run }); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +const page = (dir) => join(dir, 'row-template', 'index.html'); +const last = (host) => rebeccaRow(host.db).at(-1); + +/* --- detection and the database ------------------------------------------- */ + +test('detection needs two independent signals', () => { + const cases = [ + [{}, 0, 'the official layout'], + [{ compose: false, cli: false }, 0, '.env and the data directory'], + ]; + for (const [opts, want, label] of cases) { + withHost(opts, ({ run }) => { + const r = run('rc=0; rt_panel_detect rebecca || rc=$?; echo "rc=$rc"'); + assert.match(r.out, new RegExp(`rc=${want}`), `${label}\n${r.err}`); + }); + } + withHost({ compose: false, cli: false }, ({ host, run }) => { + rmSync(host.envFile); + const r = run('rc=0; rt_panel_detect rebecca || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/, 'the data directory alone is one signal: FAILURE'); + }); +}); + +test('capabilities are exactly what the adapter implements', () => { + withHost({}, ({ run }) => { + const r = run('rt_panel_capabilities rebecca'); + assert.equal(r.code, 0, r.err); + assert.equal(r.out, ['db_activation', 'file_placement', 'selection_read', 'selection_write', 'static_verify'].join('\n')); + }); +}); + +test('only a sqlite: database inside the shared data directory is ever used', () => { + withHost({}, ({ host, run }) => { + let r = run('rt_panel_rebecca_db'); + assert.equal(r.out, posix(host.db), 'the official sqlite URL resolves to the database'); + const setUrl = (url) => writeFileSync(host.envFile, `SUDO_PASSWORD = "x"\nSQLALCHEMY_DATABASE_URL = "${url}"\n`); + for (const url of ['mysql+pymysql://rebecca:Sup3rS3cret@127.0.0.1:3306/rebecca', + 'sqlite:///db.sqlite3', 'sqlite:////etc/elsewhere/db.sqlite3']) { + setUrl(url); + r = run('rc=0; rt_panel_rebecca_db || rc=$?; echo "rc=$rc"; rc=0; rt_panel_status rebecca; echo'); + assert.match(r.out, /rc=1/, `${url} must not be used`); + assert.match(r.out, /manual/, 'activation becomes manual'); + assert.equal(r.out.includes('Sup3rS3cret') || r.err.includes('Sup3rS3cret'), false, 'a password is never printed'); + } + }); +}); + +/* --- which Rebecca -------------------------------------------------------------- + Found on a real host during 1.3.0 validation: Docker Hub's + rebeccapanel/rebecca:latest -- what Rebecca's Docker installer pulls -- is the + 0.0.x Python edition, not the 1.x Go edition this page is built for. There the + selection is written and accepted, the page cannot render, and Rebecca serves + its own page instead: an install that reported success and changed nothing a + subscriber saw. The edition is now established first. */ + +test('the Rebecca edition is told apart: 1.x (Go) from 0.0.x (Python), unknown fails closed', () => { + const cases = [[{ edition: 'go' }, 'go'], [{ edition: 'python' }, 'python'], [{ edition: null }, 'unknown']]; + for (const [opts, want] of cases) { + withHost(opts, ({ run }) => { + const r = run('rt_panel_rebecca_edition; echo; rc=0; rt_panel_rebecca_edition_ok 2>/dev/null || rc=$?; echo "ok=$rc"'); + assert.match(r.out, new RegExp(`^${want}$`, 'm'), `${JSON.stringify(opts)}\n${r.err}`); + assert.match(r.out, want === 'go' ? /ok=0/ : /ok=1/); + }); + } + withHost({ edition: null }, ({ run }) => { + const r = run('rt_panel_rebecca_mode() { printf binary; }; rt_panel_rebecca_edition'); + assert.equal(r.out, 'go', 'a binary install is 1.x: the Python edition has none'); + }); +}); + +test('on the 0.0.x Python edition, install is refused before anything is written', () => { + withHost({ edition: 'python' }, ({ host, rt, run }) => { + const before = last(host); + const r = run(['export RT_ASSUME_NONINTERACTIVE=1 RT_SERVICE_NAME="X"', 'rt_cmd_install "$PAYLOAD" { + const before = last(host); + const r = run(['export RT_ASSUME_NONINTERACTIVE=1', 'rt_cmd_install "$PAYLOAD" { + withHost({}, ({ host, run }) => { + const before = last(host); + run(SETUP); + // the image changes under an existing install (a re-pull of :latest) + writeFileSync(join(host.docker, 'inspect'), '["/code/scripts/entrypoint.sh"] null /code\n'); + const r = run('rc=0; rt_transaction_run rebecca "$RT_LIVE" || rc=$?; echo "txn=$rc"'); + assert.match(r.out, /txn=1/, 'the transaction stops at capture, before any change'); + assert.match(r.err, /Python edition/); + assert.deepEqual(last(host), before); + assert.equal(existsSync(join(host.dataDir, 'templates')), false, 'no page was placed'); + const f = run('rc=0; rt_panel_refresh_page rebecca "$RT_LIVE" || rc=$?; echo "refresh=$rc"'); + assert.match(f.out, /refresh=1/, 'and a refresh refuses it as well'); + }); + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null']); + writeFileSync(join(host.docker, 'inspect'), '["/code/scripts/entrypoint.sh"] null /code\n'); + const r = run('rc=0; rt_panel_verify rebecca static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/, 'a page Rebecca cannot render is not a passing install'); + assert.match(r.err, /Python edition/); + }); +}); + +/* --- activation, rollback, uninstall ------------------------------------- */ + +test('activation sets two columns of the newest row and nothing else, with no restart', () => { + withHost({ rows: 2 }, ({ host, run }) => { + const before = rebeccaRow(host.db); + const r = run([SETUP, 'rc=0; rt_transaction_run rebecca "$RT_LIVE" || rc=$?; echo "rc=$rc"', + 'rc=0; rt_panel_verify rebecca static || rc=$?; echo "static=$rc"']); + assert.match(r.out, /rc=0/, r.err); + assert.match(r.out, /static=0/, r.err); + const after = rebeccaRow(host.db); + assert.deepEqual(after[0], before[0], 'an older row is untouched'); + const want = [...before[1]]; + want[1] = 'row-template/index.html'; + want[2] = posix(join(host.dataDir, 'templates')); + assert.deepEqual(after[1], want, 'exactly the page and directory columns of the row Rebecca reads'); + assert.equal(existsSync(page(join(host.dataDir, 'templates'))), true, 'the page is placed'); + const calls = existsSync(join(host.docker, 'calls')) ? readFileSync(join(host.docker, 'calls'), 'utf8').split(/\r?\n/) : []; + assert.equal(calls.some((l) => l.startsWith('compose')), false, 'Rebecca is never restarted'); + }); +}); + +test('rollback and uninstall restore NULL, empty and a value exactly', () => { + for (const customDir of [null, '']) { + withHost({ customDir }, ({ host, run }) => { + const before = last(host); + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null', + 'printf "%s\\n" "$(basename "$RT_TXN_SNAPSHOT")" > "$RT_PANEL_ACTIVATION"']); + assert.equal(last(host)[1], 'row-template/index.html'); + const r = run(['RT_ACTIVE_PANEL=rebecca', 'rt_panel_uninstall_template rebecca; echo "rc=$?"']); + assert.match(r.out, /rc=0/, r.err); + assert.deepEqual(last(host), before, `custom_templates_directory ${JSON.stringify(customDir)} is restored exactly`); + assert.equal(existsSync(join(host.dataDir, 'templates')), false, 'the page and the directory it needed are removed'); + }); + } + withHost({ customDir: '' }, ({ host, run }) => { + const before = last(host); + writeFileSync(join(host.docker, 'unused'), ''); + const r = run([SETUP, 'rc=0', + // a failure after the change: the placed page is sabotaged so the + // post-change static verification fails and the engine rolls back + 'rt_panel_rebecca_place_orig="$(declare -f rt_panel_rebecca_place)"', + 'rt_panel_rebecca_place() { eval "${rt_panel_rebecca_place_orig/rt_panel_rebecca_place/rt_orig_place}"; rt_orig_place "$@" && printf "tampered" >> "$2/row-template/index.html"; }', + 'out="$(rt_panel_activate)" || rc=$?; echo "rc=$rc out=$out"']); + assert.match(r.out, /rc=1 out=$/, r.err); + assert.deepEqual(last(host), before, "rollback restores '' exactly, not NULL"); + assert.match(r.err, /Rebecca was restored exactly to its state before the attempt/, + 'the operator is told the truth: the restore was exact'); + assert.doesNotMatch(r.err, /rollback also failed/, "and not the engine's conservative post-check"); + assert.match(r.err, /placed page differs/, 'the real cause is shown'); + }); +}); + +test('an operator directory is used and kept; only the page column changes', () => { + withHost({}, ({ host, run }) => { + const own = join(host.dataDir, 'my templates'); + mkdirSync(join(own, 'subscription'), { recursive: true }); + writeFileSync(join(own, 'subscription', 'index.html'), 'operator page'); + run(`python=1; true`); + const ownPosix = posix(own); + const r0 = run(`sqlite3 "$(cygpath -u '${host.db.split('\\').join('/')}' 2>/dev/null || printf '%s' '${host.db.split('\\').join('/')}')" "UPDATE subscription_settings SET custom_templates_directory = '${ownPosix}'"`, + { RT_TEST_PYTHON: undefined }); + assert.equal(r0.code, 0, r0.err); + const before = last(host); + const r = run([SETUP, 'rc=0; rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null || rc=$?; echo "rc=$rc"', + 'printf "%s\\n" "$(basename "$RT_TXN_SNAPSHOT")" > "$RT_PANEL_ACTIVATION"']); + assert.match(r.out, /rc=0/, r.err); + assert.equal(last(host)[2], ownPosix, 'the operator directory is kept'); + assert.equal(existsSync(page(own)), true, 'the page goes into it'); + assert.equal(readFileSync(join(own, 'subscription', 'index.html'), 'utf8'), 'operator page'); + run('rt_panel_uninstall_template rebecca'); + assert.deepEqual(last(host), before); + assert.equal(existsSync(own), true, 'the operator directory stays'); + assert.equal(existsSync(join(own, 'row-template')), false); + }); +}); + +test('a quote in panel data cannot break the SQL', () => { + withHost({ customDir: "/srv/it's here" }, ({ host, run }) => { + const before = last(host); + // the directory is outside the shared data dir, so activation is refused + // -- but reading and restoring it goes through the quoting all the same + const r = run([SETUP, 'rt_panel_rebecca_db_ready', 'rt_panel_rebecca_dir_get; echo', + 'rt_panel_rebecca_write "x\'); DROP TABLE admins; --" present "$(rt_panel_rebecca_dir_get | cut -d: -f2-)"', + 'rt_panel_rebecca_page_get; echo']); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /present:\/srv\/it's here/); + assert.match(r.out, /x'\); DROP TABLE admins; --/, 'the value is stored as data'); + assert.equal(last(host)[2], before[2], 'the quoted directory round-trips'); + const tables = run(`sqlite3 "$(cygpath -u '${host.db.split('\\').join('/')}' 2>/dev/null || printf '%s' '${host.db.split('\\').join('/')}')" "SELECT count(*) FROM admins"`); + assert.equal(tables.code, 0, 'the admins table still exists'); + }); +}); + +test('uninstall without an activation record returns Rebecca to its default page', () => { + withHost({}, ({ host, run }) => { + const before = last(host); + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null', 'rm -f "$RT_PANEL_ACTIVATION"']); + const r = run('rt_panel_uninstall_template rebecca; echo "rc=$?"'); + assert.match(r.out, /rc=0/, r.err); + assert.deepEqual(last(host), before, "the default page, and NULL for the directory Row-Template set"); + }); +}); + +test('an operator who moved away from Row-Template keeps their choice on uninstall', () => { + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null']); + const db = `"$(cygpath -u '${host.db.split('\\').join('/')}' 2>/dev/null || printf '%s' '${host.db.split('\\').join('/')}')"`; + run(`sqlite3 ${db} "UPDATE subscription_settings SET subscription_page_template = 'mine/page.html'"`); + const chosen = last(host); + const r = run('rt_panel_uninstall_template rebecca; echo "rc=$?"'); + assert.match(r.out, /rc=0/, r.err); + assert.deepEqual(last(host), chosen, 'the selection is theirs and is left alone'); + assert.equal(existsSync(page(join(host.dataDir, 'templates'))), false, 'our page is still removed'); + }); +}); + +test('admins who override the page for their users are reported by verify', () => { + withHost({ admins: ['{"subscription_page_template": "vip/index.html"}', '{}', '{"custom_templates_directory":"/x"}'] }, ({ run }) => { + const r = run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null', 'rt_panel_verify rebecca static; echo "rc=$?"']); + assert.match(r.out, /rc=0/); + assert.match(r.err, /2 admin\(s\) override the subscription page/); + }); +}); + +/* --- refusals: a foreign page, a bad record, a tampered page -------------------- */ + +test('a page that is not Row-Template\'s is never overwritten, and the failed activation changes nothing', () => { + withHost({}, ({ host, run }) => { + const before = last(host); + const tpl = join(host.dataDir, 'templates'); + mkdirSync(join(tpl, 'row-template'), { recursive: true }); + writeFileSync(page(tpl), '

someone else

'); + const r = run([SETUP, 'rc=0; rt_transaction_run rebecca "$RT_LIVE" || rc=$?; echo "rc=$rc"']); + assert.match(r.out, /rc=1/, r.err); + assert.match(r.err, /exists and is not Row-Template's/); + assert.equal(readFileSync(page(tpl), 'utf8'), '

someone else

', 'the operator file is untouched'); + assert.deepEqual(last(host), before, 'the settings row is untouched'); + }); +}); + +test('restore refuses a record that lists a file this adapter never places', () => { + withHost({}, ({ run }) => { + const r = run([SETUP, + 'rt_transaction_stage_reset', 'rt_panel_backup_state rebecca', + 'printf "../../etc/passwd\\n" > "$RT_PANEL_STAGE/rebecca/files"', + 'snap="$(rt_backup_create v2 rebecca 2>/dev/null)" || { echo "snapshot-refused"; exit 0; }', + 'rc=0; rt_panel_restore_state rebecca "$snap" || rc=$?; echo "rc=$rc"']); + assert.match(r.out, /snapshot-refused|rc=1/, r.err); + }); +}); + +test('verify fails a placed page that was changed, and a selection that was moved away', () => { + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null']); + const tpl = join(host.dataDir, 'templates'); + const good = readFileSync(page(tpl)); + writeFileSync(page(tpl), good.toString().replace('Test VPN', 'Tampered')); + let r = run('rc=0; rt_panel_verify rebecca static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/); + assert.match(r.err, /placed page differs/); + writeFileSync(page(tpl), good); + const db = `"$(cygpath -u '${host.db.split('\\').join('/')}' 2>/dev/null || printf '%s' '${host.db.split('\\').join('/')}')"`; + run(`sqlite3 ${db} "UPDATE subscription_settings SET subscription_page_template = 'subscription/index.html'"`); + r = run('rc=0; rt_panel_verify rebecca static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/); + assert.match(r.err, /does not select the Row-Template page/); + }); +}); + +test('secrets in .env never reach output, logs or snapshots', () => { + withHost({}, ({ rt, run }) => { + const r = run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE"', 'rt_panel_verify rebecca static', + 'rt_panel_verify rebecca live', 'rt_panel_status rebecca', 'rt_panel_uninstall_template rebecca']); + const secret = 'rebecca-secret-do-not-leak'; + assert.equal(r.out.includes(secret) || r.err.includes(secret), false, 'not in output'); + const walk = (d) => readdirSync(d, { withFileTypes: true }).flatMap((e) => + (e.isDirectory() ? walk(join(d, e.name)) : [join(d, e.name)])); + for (const f of existsSync(rt) ? walk(rt) : []) { + assert.equal(readFileSync(f).includes(secret), false, `not in ${f}`); + } + }); +}); + +/* --- the artifact must fit the panel ---------------------------------------- */ + +test('a 3X-UI artifact, a PasarGuard page, or a shell from before 1.3.0, is refused on Rebecca', () => { + withHost({}, ({ base, run }) => { + const old = join(base, 'old-shell.html'); + const shell = readFileSync(join(PAYLOAD, 'shells', 'rebecca', 'row', 'shell.html'), 'utf8'); + // a 1.2.x shell: the same layout without the context prelude and escaping + writeFileSync(old, shell.replace('Row-Template, Rebecca page context', '') + .replace('{%- autoescape on -%}', '').replace('{%- endautoescape %}', '')); + const u = (p) => `"$(cygpath -u ${JSON.stringify(p.split('\\').join('/'))} 2>/dev/null || printf '%s' ${JSON.stringify(p.split('\\').join('/'))})"`; + const r = run([SETUP, + 'rc=0; rt_set_dist "$PAYLOAD/template.html" 2>/dev/null || rc=$?; echo "xui=$rc"', + 'rc=0; rt_set_dist "$PAYLOAD/shells/pasarguard/row/shell.html" 2>/dev/null || rc=$?; echo "pg=$rc"', + `rc=0; rt_set_dist ${u(old)} 2>/dev/null || rc=$?; echo "old=$rc"`, + 'rc=0; rt_set_dist "$RT_TEMPLATE_STORE/editorial/template.html" || rc=$?; echo "ok=$rc"']); + assert.match(r.out, /xui=1/, 'the 3X-UI artifact is refused'); + assert.match(r.out, /pg=1/, 'a PasarGuard page is refused'); + assert.match(r.out, /old=1/, 'a shell without the prelude and escaping is refused'); + assert.match(r.out, /ok=0/, `a 1.3.0 Rebecca page is accepted\n${r.err}`); + }); +}); + +test('a backup made for another panel is never restored onto Rebecca', () => { + withHost({}, ({ run }) => { + const r = run([SETUP, + 'B="$(rt_backup_create)"', + 'sed -i "s/^panel=rebecca$/panel=pasarguard/" "$B/meta"', + 'rc=0; rt_restore_from_backup "$B" 2>/dev/null || rc=$?; echo "rc=$rc"', + 'B2="$(rt_backup_create)"; grep -c "^panel=rebecca$" "$B2/meta"']); + assert.match(r.out, /rc=1/, 'refused'); + assert.match(r.out, /\n1$/, 'and a Rebecca backup records its panel'); + }); +}); + +/* rt_activate swaps sub.html and then refreshes the panel's copy. Raised in + review (PR #6): when that refresh failed it returned an error with sub.html + already replaced, contradicting its own contract, so a caller reporting + "nothing was changed" was wrong. The previous sub.html is now put back. */ +test('a failed panel refresh leaves sub.html and the placed page exactly as they were', () => { + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null']); + const placed = page(join(host.dataDir, 'templates')); + const beforePlaced = readFileSync(placed); + const r = run([ + 'RT_ACTIVE_PANEL=rebecca', + 'before="$(rt_sha256 "$RT_LIVE")"', + 'rt_config_write "Changed Name" "" "" ""', + 'rt_panel_refresh_page() { return 1; }', + 'rc=0; rt_activate 2>/dev/null || rc=$?; echo "rc=$rc"', + '[ "$(rt_sha256 "$RT_LIVE")" = "$before" ] && echo "live-unchanged"', + 'ls "$(dirname "$RT_LIVE")" | grep -c "^\\.prev\\." || true', + ]); + assert.match(r.out, /rc=1/, 'the failure is reported'); + assert.match(r.out, /live-unchanged/, 'sub.html is back to its previous bytes'); + assert.match(r.out, /^0$/m, 'no temporary copy is left behind'); + assert.ok(readFileSync(placed).equals(beforePlaced), 'the page Rebecca serves never changed'); + }); +}); + +/* --- manual activation --------------------------------------------------------- */ + +test('without sqlite3, activation places the page and says exactly what to set', () => { + withHost({ sqlite: false }, ({ host, run }) => { + const before = last(host); + const r = run([SETUP, 'rt_panel_status rebecca; echo', 'out="$(rt_panel_activate)"; echo "outcome=$out"', 'rt_panel_manual_steps']); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /^manual/m); + assert.match(r.out, /outcome=manual/); + assert.match(r.out, /Subscription page template:\s+row-template\/index.html/); + assert.equal(existsSync(page(join(host.dataDir, 'templates'))), true, 'the page is in place for the operator to select'); + assert.deepEqual(last(host), before, 'and the database was not touched'); + }); +}); + +/* --- the whole life cycle ------------------------------------------------------ */ + +test('install, verify, rebrand, switch design, roll back and uninstall on a Rebecca host', () => { + withHost({}, ({ base, host, rt, run }) => { + const rowBefore = last(host); + const tpl = join(host.dataDir, 'templates'); + let r = run(['export RT_ASSUME_NONINTERACTIVE=1 RT_SERVICE_NAME="Aurora Net" RT_SUPPORT_URL="" RT_LOGO_REMOVE=1', + 'rt_cmd_install "$PAYLOAD" /. A store anywhere else is invisible to it, and + * the manager then reports "No templates are installed" while the files sit one + * directory away. Two states reach that on real hosts: + * + * - the store is ABSENT: v1.1.0's updater installs a v1.2.0 library but copies + * only four files, so no design arrives with it; + * - the store is MISPLACED: a release payload lays its designs out at + * templates//, and a payload copied or extracted over the install root + * leaves them at $RT_ROOT/templates, one level above their home. + * + * rt_repair_template_store heals both from what the host already has (a verified + * payload, a misplaced store), and rt_complete_install finishes an install that + * is still short afterwards from a verified download of the INSTALLED version. + * Every case below runs against a throwaway RT_ROOT, never a real install. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, +} from 'node:fs'; +import { tmpdir, platform } from 'node:os'; +import { createHash } from 'node:crypto'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { build } from '../tools/build.mjs'; +import { availableTemplateIds, TEMPLATES } from '../tools/templates.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const IDS = availableTemplateIds(); +const VERSION = readFileSync(join(ROOT, 'VERSION'), 'utf8').trim(); + +/* Every selectable design, built once. Row is the committed artifact. */ +const HTML = Object.fromEntries(IDS.map((id) => [id, + id === 'row' ? readFileSync(join(ROOT, 'template', 'index.html'), 'utf8') : build(true, id).html])); +const sha = (s) => createHash('sha256').update(s).digest('hex'); + +/* One design directory, with its sidecar written the way make-release.sh + writes it (the digest, two spaces, the payload-relative path). */ +function writeDesign(dir, id, html = HTML[id]) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'template.html'), html); + writeFileSync(join(dir, 'template.html.sha256'), `${sha(HTML[id])} templates/${id}/template.html\n`); +} + +function writeStore(dir, ids = IDS) { + for (const id of ids) writeDesign(join(dir, id), id); +} + +/* An installed v1.2.0 with NO store: what v1.1.0's updater leaves behind. The + canonical artifact is Row, the live page exists, branding is set and there + is one format-1 backup from before the update. */ +function prepareInstalled(root) { + mkdirSync(join(root, 'dist'), { recursive: true }); + writeFileSync(join(root, 'dist', 'template.html'), HTML.row); + writeFileSync(join(root, 'dist', 'template.html.sha256'), sha(HTML.row) + ' template.html\n'); + writeFileSync(join(root, 'sub.html'), HTML.row); + writeFileSync(join(root, 'VERSION'), VERSION + '\n'); + writeFileSync(join(root, 'config.env'), [ + 'RT_CONFIG_VERSION=1', + 'SERVICE_NAME_B64=' + Buffer.from('Test VPN', 'utf8').toString('base64'), + 'SUPPORT_URL_B64=', + 'LOGO_MIME=', + 'LOGO_DATA_B64=', + '', + ].join('\n')); + const bk = join(root, 'backups', '20260101T000000Z__1.1.0'); + mkdirSync(bk, { recursive: true }); + writeFileSync(join(bk, 'template.html'), HTML.row); + writeFileSync(join(bk, 'template.html.sha256'), sha(HTML.row) + '\n'); + writeFileSync(join(bk, 'VERSION'), '1.1.0\n'); +} + +/* A release payload of VERSION with every design and the library's companions, + as tools/make-release.sh lays it out. */ +function writePayload(dir, { version = VERSION, ids = IDS } = {}) { + mkdirSync(join(dir, 'lib'), { recursive: true }); + mkdirSync(join(dir, 'panels'), { recursive: true }); + writeFileSync(join(dir, 'template.html'), HTML.row); + writeFileSync(join(dir, 'VERSION'), version + '\n'); + copyFileSync(join(ROOT, 'installer', 'lib', 'row-template.sh'), join(dir, 'lib', 'row-template.sh')); + copyFileSync(join(ROOT, 'installer', 'lib', 'transaction.sh'), join(dir, 'lib', 'transaction.sh')); + for (const f of readdirSync(join(ROOT, 'installer', 'panels'))) { + copyFileSync(join(ROOT, 'installer', 'panels', f), join(dir, 'panels', f)); + } + writeStore(join(dir, 'templates'), ids); +} + +/* No release source is reachable unless a test provides one, so nothing here + can reach the network; a test that needs a download redefines + rt_fetch_release after these. */ +const STUBS = [ + 'rt_require_root(){ :; }', + 'rt_detect_xui(){ return 1; }', + 'rt_detect_xui_version(){ return 1; }', + 'rt_detect_xui_db(){ return 1; }', + 'rt_fetch_release(){ return 1; }', + 'trap "rt_cleanup" EXIT', + '', +].join('\n'); + +/* Run BODY with the INSTALLED library layout: the library is copied into the + sandbox's lib/ and sourced from there, so the companion loader looks where a + real install keeps its companions -- and finds them only if they are there. */ +function run(body, { prepare, env } = {}) { + const base = mkdtempSync(join(tmpdir(), 'row-store-')).replace(/\\/g, '/'); + const root = `${base}/rt`; + try { + mkdirSync(join(root, 'lib'), { recursive: true }); + copyFileSync(join(ROOT, 'installer', 'lib', 'row-template.sh'), join(root, 'lib', 'row-template.sh')); + if (prepare) prepare(root, base); + const script = [ + 'set -Eeuo pipefail', + 'unset RT_TEMPLATE RT_RELEASE_URL RT_RELEASE_DIR RT_ASSUME_YES XUI_DB_FOLDER', + `export RT_ROOT="${root}" RT_BIN="${base}/row-template"`, + 'BASE="' + base + '"', + '. "$RT_ROOT/lib/row-template.sh"', + STUBS, + body, + ].join('\n'); + const r = spawnSync('bash', ['-c', script], { + cwd: ROOT, encoding: 'utf8', env: env ? { ...process.env, ...env } : undefined, + }); + if (r.error) throw r.error; + return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +/* The store's contents as the library itself reads them, plus whether the + misplaced directory still exists. Printed by the bash side, because the + sandbox is gone once run() returns. */ +const REPORT = [ + 'printf "store=%s\\n" "$(rt_template_store_ids | tr "\\n" "," )"', + 'printf "offered=%s\\n" "$(rt_template_offered | wc -l | tr -d " ")"', + 'if rt_template_verify_store; then echo "verified=yes"; else echo "verified=no"; fi', + '[ -e "$RT_ROOT/templates" ] && echo "misplaced=present" || echo "misplaced=gone"', +].join('\n'); + +const ALL = IDS.slice().sort().join(',') + ','; +const field = (out, key) => (out.match(new RegExp(`^${key}=(.*)$`, 'm')) || [])[1]; + +/* --- the misplaced store ----------------------------------------------------- */ + +test('a store misplaced at $RT_ROOT/templates is moved into dist/templates and verified', () => { + const r = run([ + 'rc=0; rt_repair_template_store || rc=$?', + 'echo "rc=$rc"', + REPORT, + 'for id in ' + IDS.join(' ') + '; do', + ' cmp -s "$RT_TEMPLATE_STORE/$id/template.html" "$BASE/expect/$id/template.html" || echo "differs=$id"', + 'done', + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(root, 'templates')); + writeStore(join(base, 'expect')); + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'rc'), '0', 'a complete store after repair reports success'); + assert.equal(field(r.out, 'store'), ALL, 'every design is in the canonical store'); + assert.equal(field(r.out, 'offered'), String(IDS.length), 'and the chooser offers every one'); + assert.equal(field(r.out, 'verified'), 'yes'); + assert.equal(field(r.out, 'misplaced'), 'gone', 'the misplaced copy is retired once covered'); + assert.doesNotMatch(r.out, /differs=/, 'the designs are moved byte for byte'); + assert.match(r.out, new RegExp(`moved ${IDS.length} design`), 'the repair says what it did'); +}); + +test('a mixed install is repaired as far as the host allows, then completed from a payload', () => { + /* The broken layout from the report: two designs at the root, only the + canonical artifact under dist/. With nothing else on the host the repair + recovers those two and names the rest; with a payload it completes. */ + const local = run([ + 'rc=0; rt_repair_template_store || rc=$?', + 'echo "rc=$rc"', + 'printf "missing=%s\\n" "$(rt_template_store_missing | tr "\\n" ",")"', + REPORT, + ].join('\n'), { + prepare: (root) => { prepareInstalled(root); writeStore(join(root, 'templates'), ['row', 'prism']); }, + }); + assert.equal(local.code, 0, local.err); + assert.equal(field(local.out, 'rc'), '2', 'an incomplete store is reported as incomplete, not as success'); + assert.equal(field(local.out, 'store'), 'prism,row,'); + assert.equal(field(local.out, 'missing'), IDS.filter((id) => !['row', 'prism'].includes(id)).join(',') + ',', + 'every design still missing is named, in catalogue order'); + assert.equal(field(local.out, 'misplaced'), 'gone'); + + const payload = run([ + 'rc=0; rt_repair_template_store "$BASE/payload" || rc=$?', + 'echo "rc=$rc"', + REPORT, + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(root, 'templates'), ['row', 'prism']); + writePayload(join(base, 'payload')); + }, + }); + assert.equal(payload.code, 0, payload.err); + assert.equal(field(payload.out, 'rc'), '0'); + assert.equal(field(payload.out, 'store'), ALL); + assert.equal(field(payload.out, 'misplaced'), 'gone'); +}); + +test('a corrupt design is detected, replaced from a good copy, and never migrated when it is the copy', () => { + const r = run([ + 'rc=0; rt_repair_template_store 2>"$BASE/err" || rc=$?', + 'echo "rc=$rc"', + 'cat "$BASE/err"', + REPORT, + 'printf "missing=%s\\n" "$(rt_template_store_missing | tr "\\n" ",")"', + 'cmp -s "$RT_TEMPLATE_STORE/editorial/template.html" "$BASE/good-editorial.html" && echo "editorial=replaced"', + '[ -e "$RT_TEMPLATE_STORE/prism" ] && echo "prism=staged" || echo "prism=refused"', + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + // the canonical store is complete except that editorial was tampered with + // and prism is missing + writeStore(join(root, 'dist', 'templates'), IDS.filter((id) => id !== 'prism')); + writeFileSync(join(root, 'dist', 'templates', 'editorial', 'template.html'), HTML.editorial + 'x'); + // the misplaced store has a good editorial and a corrupt prism + writeDesign(join(root, 'templates', 'editorial'), 'editorial'); + writeDesign(join(root, 'templates', 'prism'), 'prism', HTML.prism.replace('', '')); + writeFileSync(join(base, 'good-editorial.html'), HTML.editorial); + }, + }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /editorial=replaced/, 'a damaged design is replaced from a copy that verifies'); + assert.match(r.out, /prism=refused/, 'a copy that fails its own checksum is never migrated'); + assert.match(r.out, /prism[^\n]*(checksum|verification)/, 'and the refusal names the design'); + assert.equal(field(r.out, 'rc'), '2'); + assert.equal(field(r.out, 'missing'), 'prism,'); + assert.equal(field(r.out, 'verified'), 'yes', 'what is in the store afterwards all verifies'); +}); + +test('the repair never follows a symlink out of the install root', { skip: platform() !== 'linux' }, () => { + const r = run([ + 'rc=0; rt_repair_template_store 2>/dev/null || rc=$?', + 'echo "rc=$rc"', + REPORT, + '[ -f "$BASE/outside/row/template.html" ] && echo "outside=intact"', + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(base, 'outside'), ['row']); + symlinkSync(join(base, 'outside'), join(root, 'templates')); + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), '', 'nothing is taken through the link'); + assert.match(r.out, /outside=intact/, 'and nothing it points at is removed'); +}); + +test('files the repair does not recognise are left where they are', () => { + const r = run([ + 'rt_repair_template_store >/dev/null 2>&1 || true', + REPORT, + '[ -f "$RT_ROOT/templates/notes.txt" ] && echo "notes=kept"', + '[ -f "$RT_ROOT/templates/Custom/template.html" ] && echo "custom=kept"', + ].join('\n'), { + prepare: (root) => { + prepareInstalled(root); + writeStore(join(root, 'templates')); + writeFileSync(join(root, 'templates', 'notes.txt'), 'mine\n'); + mkdirSync(join(root, 'templates', 'Custom')); + writeFileSync(join(root, 'templates', 'Custom', 'template.html'), 'x'); + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), ALL); + assert.match(r.out, /notes=kept/); + assert.match(r.out, /custom=kept/); + assert.equal(field(r.out, 'misplaced'), 'present', 'a directory still holding foreign files stays'); +}); + +/* --- the entry points: install, update, verify ------------------------------- */ + +test('verify moves a misplaced store and then reports it healthy', () => { + const r = run('rt_cmd_verify', { + prepare: (root) => { prepareInstalled(root); writeStore(join(root, 'templates')); }, + }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, new RegExp(`moved ${IDS.length} design`)); + assert.match(r.out, new RegExp(`Template store verified \\(${IDS.length} design`)); + assert.doesNotMatch(r.err, /template store missing or empty/); +}); + +test('verify completes an install the v1.1.0 updater left short, then passes', () => { + /* The installation guide's own sequence: update, then verify. verify must not + fail on a state the next step of the same update resolves. */ + const r = run([ + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'rt_cmd_verify', + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, new RegExp(`Installation completed: ${IDS.length} design`)); + assert.match(r.out, new RegExp(`Template store verified \\(${IDS.length} design`)); + assert.match(r.out, /Installer components present/); + assert.doesNotMatch(r.err, /template store missing or empty|installer components are missing/); +}); + +test('verify without a reachable release reports the gap and the remedy', () => { + const r = run('rt_cmd_verify', { prepare: prepareInstalled }); + assert.equal(r.code, 1, 'an empty store is still a hard failure'); + assert.match(r.err, /could not download/); + assert.match(r.err, /template store missing or empty; run 'row-template update'/); +}); + +test('verify names missing and corrupt designs', () => { + const partial = run('rt_cmd_verify', { + prepare: (root) => { prepareInstalled(root); writeStore(join(root, 'dist', 'templates'), IDS.slice(0, -2)); }, + }); + assert.equal(partial.code, 0, 'a partial store is a warning: every installed design still works\n' + partial.err); + assert.match(partial.err, new RegExp(`template store is incomplete[^\\n]*${IDS.at(-2)}[^\\n]*${IDS.at(-1)}`), + 'the missing designs are named'); + + const corrupt = run('rt_cmd_verify', { + prepare: (root) => { + prepareInstalled(root); + writeStore(join(root, 'dist', 'templates')); + writeFileSync(join(root, 'dist', 'templates', 'canvas', 'template.html'), HTML.canvas + 'x'); + }, + }); + assert.equal(corrupt.code, 1, 'a design that fails its checksum is a hard failure'); + assert.match(corrupt.err, /does not match its checksum[^\n]*canvas/, 'and the design is named'); +}); + +test('update moves a misplaced store and keeps every backup it found', () => { + const r = run([ + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'rt_cmd_update >/dev/null', + REPORT, + '[ -d "$RT_BACKUPS/20260101T000000Z__1.1.0" ] && echo "old-backup=kept"', + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(root, 'templates')); + writePayload(join(base, 'payload')); + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), ALL); + assert.equal(field(r.out, 'misplaced'), 'gone'); + assert.match(r.out, /old-backup=kept/); +}); + +test('install (repair of an existing root) moves a misplaced store', () => { + const r = run([ + 'rt_detect_xui(){ RT_XUI_UNIT="x-ui.service"; return 0; }', + 'rt_detect_xui_version(){ RT_XUI_VERSION="3.7.0"; printf "3.7.0"; }', + 'rt_service_active(){ return 1; }; rt_service_start(){ :; }; rt_service_stop(){ :; }', + 'RT_ASSUME_YES=1 rt_cmd_install "$BASE/payload" /dev/null', + REPORT, + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(root, 'templates'), ['row', 'canvas']); + writePayload(join(base, 'payload')); + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), ALL); + assert.equal(field(r.out, 'misplaced'), 'gone'); +}); + +test('a fresh install never creates a store outside dist/templates', () => { + const r = run([ + 'rt_detect_xui(){ RT_XUI_UNIT="x-ui.service"; return 0; }', + 'rt_detect_xui_version(){ RT_XUI_VERSION="3.7.0"; printf "3.7.0"; }', + 'rt_service_active(){ return 1; }; rt_service_start(){ :; }; rt_service_stop(){ :; }', + 'RT_SERVICE_NAME="Fresh" rt_cmd_install "$BASE/payload" /dev/null', + REPORT, + ].join('\n'), { + prepare: (root, base) => { writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), ALL); + assert.equal(field(r.out, 'verified'), 'yes'); + assert.equal(field(r.out, 'misplaced'), 'gone'); +}); + +/* --- completing an install the v1.1.0 updater left short ---------------------- */ + +test('the first run of the new code completes the install from the installed version', () => { + const r = run([ + 'rt_fetch_release(){ echo "fetched $RT_RELEASE_URL" >> "$BASE/fetch.log"; printf "%s" "$BASE/payload"; }', + 'rc=0; rt_complete_install || rc=$?', + 'echo "rc=$rc"', + REPORT, + 'echo "panels=${RT_PANELS_LOADED:-} txn=${RT_TRANSACTION_LOADED:-}"', + 'rt_installer_complete && echo "complete=yes" || echo "complete=no"', + 'for f in lib/transaction.sh panels/index.sh panels/interface.sh panels/3xui.sh; do', + ' [ -f "$RT_ROOT/$f" ] || echo "absent=$f"', + 'done', + 'cat "$BASE/fetch.log"', + 'cmp -s "$RT_DIST" "$BASE/payload/template.html" && echo "dist=untouched"', + 'printf "name=%s\\n" "$(rt_config_get_text SERVICE_NAME_B64)"', + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'rc'), '0'); + assert.equal(field(r.out, 'store'), ALL, 'every design is installed'); + assert.equal(field(r.out, 'offered'), String(IDS.length)); + assert.match(r.out, /panels=1 txn=1/, 'the installer components load in the same run'); + assert.equal(field(r.out, 'complete'), 'yes'); + assert.doesNotMatch(r.out, /absent=/); + assert.match(r.out, new RegExp(`fetched https://github\\.com/iitzSeriZdev/Row-Template/releases/download/v${VERSION.replace(/\./g, '\\.')}$`, 'm'), + 'the download is pinned to the INSTALLED version, never "latest"'); + assert.match(r.out, /dist=untouched/, 'the live design is not changed'); + assert.equal(field(r.out, 'name'), 'Test VPN', 'branding is not touched'); +}); + +test('completion honours an explicit release source and refuses a different version', () => { + const r = run([ + 'export RT_RELEASE_DIR="$BASE/other"', + 'rt_fetch_release(){ echo "source=dir:${RT_RELEASE_DIR:-} url:${RT_RELEASE_URL:-}" >> "$BASE/fetch.log"; return 1; }', + 'rc=0; rt_complete_install 2>"$BASE/err" || rc=$?', + 'echo "rc=$rc"', + 'cat "$BASE/fetch.log"', + 'unset RT_RELEASE_DIR', + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'rc=0; rt_complete_install 2>>"$BASE/err" || rc=$?', + 'echo "rc2=$rc"', + 'cat "$BASE/err"', + REPORT, + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload'), { version: '9.9.9' }); }, + }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /^source=dir:\S*\/other url:$/m, 'RT_RELEASE_DIR is used as given, not replaced by a pinned URL'); + assert.equal(field(r.out, 'rc'), '1', 'an unreachable source leaves the install incomplete, reported'); + assert.equal(field(r.out, 'rc2'), '1'); + assert.match(r.out, /9\.9\.9/, 'a payload of another version is refused, and says so'); + assert.equal(field(r.out, 'store'), '', 'nothing is staged from it'); +}); + +test('completion does nothing, and downloads nothing, on a complete install', () => { + const r = run([ + 'rt_fetch_release(){ echo "FETCHED"; return 1; }', + 'rc=0; rt_complete_install || rc=$?', + 'echo "rc=$rc"', + ].join('\n'), { + prepare: (root) => { + prepareInstalled(root); + writeStore(join(root, 'dist', 'templates')); + mkdirSync(join(root, 'panels'), { recursive: true }); + copyFileSync(join(ROOT, 'installer', 'lib', 'transaction.sh'), join(root, 'lib', 'transaction.sh')); + for (const f of readdirSync(join(ROOT, 'installer', 'panels'))) { + copyFileSync(join(ROOT, 'installer', 'panels', f), join(root, 'panels', f)); + } + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'rc'), '0'); + assert.doesNotMatch(r.out, /FETCHED/); + assert.equal(r.err, '', 'and prints nothing'); +}); + +test('completion from a misplaced store needs no download', () => { + const r = run([ + 'rt_fetch_release(){ echo "FETCHED"; printf "%s" "$BASE/payload"; }', + 'rt_complete_install >/dev/null 2>&1 || true', + REPORT, + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(root, 'templates')); + // the companions are present, so the store is the only gap + mkdirSync(join(root, 'panels'), { recursive: true }); + copyFileSync(join(ROOT, 'installer', 'lib', 'transaction.sh'), join(root, 'lib', 'transaction.sh')); + for (const f of readdirSync(join(ROOT, 'installer', 'panels'))) { + copyFileSync(join(ROOT, 'installer', 'panels', f), join(root, 'panels', f)); + } + writePayload(join(base, 'payload')); + }, + }); + assert.equal(r.code, 0, r.err); + assert.doesNotMatch(r.out, /FETCHED/, 'the host already had every design'); + assert.equal(field(r.out, 'store'), ALL); +}); + +/* --- what the operator sees ------------------------------------------------- */ + +test('Reconfigure branding -> Template offers every design after a v1.1.0 update', () => { + /* The report, reproduced: the store is absent, the chooser is opened. It must + complete the install and list every design -- not tell the operator to + re-run the installer. */ + const r = run([ + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'rt_reconfig_template &1', + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.doesNotMatch(r.out, /No templates are installed/); + for (const id of IDS) { + assert.match(r.out, new RegExp(`^\\s*\\d+\\s+${TEMPLATES[id].name}$`, 'm'), `${TEMPLATES[id].name} is offered`); + } +}); + +test('the manager completes the install when it opens, before the operator picks anything', () => { + const r = run([ + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'rt_manager_main /dev/null 2>&1', + REPORT, + 'rt_installer_complete && echo "complete=yes" || echo "complete=no"', + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), ALL); + assert.equal(field(r.out, 'complete'), 'yes'); +}); + +test('a branding change works on an install the v1.1.0 updater left short', () => { + /* Without a store the branding write cannot reconcile the selection (Row) and + is refused. `row-template config` completes the install first. */ + const r = run([ + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'RT_ASSUME_NONINTERACTIVE=1 RT_SERVICE_NAME="Renamed" rt_cmd_config /dev/null', + 'printf "name=%s\\n" "$(rt_config_get_text SERVICE_NAME_B64)"', + REPORT, + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'name'), 'Renamed'); + assert.equal(field(r.out, 'store'), ALL); +}); + +test('the library never creates dist/templates just by loading', () => { + const r = run('[ -e "$RT_TEMPLATE_STORE" ] && echo "store=created" || echo "store=absent"', { + prepare: prepareInstalled, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), 'absent'); +}); diff --git a/tests/installer-transaction.test.mjs b/tests/installer-transaction.test.mjs index 3088bf7..6b13135 100644 --- a/tests/installer-transaction.test.mjs +++ b/tests/installer-transaction.test.mjs @@ -109,9 +109,19 @@ const FLOCK_SHIM = [ * never read capabilities" and "the engine never took a snapshot". Appending to * a file survives the subshell. * - * The two verification counters let a scenario distinguish the engine's - * FORWARD static check from the ROLLBACK one, which is what makes "rollback - * exactly once" and "a failed rollback does not retry" observable. */ + * The verification counters make the CALL SEQUENCE observable, which is what + * pins "rollback exactly once" and "a failed rollback does not retry". + * + * STATIC IS CALLED EXACTLY ONCE, and that is the 1.3.0 correction rather than + * an accident: the engine used to run the forward static check a second time + * AFTER a rollback, asking "does this panel serve Row-Template?" -- a question + * whose honest answer after a correct rollback is no, because rollback restores + * the panel's PREVIOUS selection. That made every good rollback look failed. + * The rollback's verification now lives in the panel layer (restore_state + * returns SUCCESS only once it has read the state back), so D_VSTATIC drives + * the single forward check and nothing else. The live counter still has two + * call sites, because live verification runs on the forward path and again + * after a rollback as evidence. */ const DOUBLES = [ 'double() { printf "%s\\n" "$1" >> "${RT_TXN_LOGFILE:-/dev/null}"; }', 'rt_panel_detect() { double detect; return "${D_DETECT:-0}"; }', @@ -121,8 +131,7 @@ const DOUBLES = [ 'rt_panel_verify() {', ' double "verify:$2"', ' case "$2" in', - ' static) D_VS_N=$(( ${D_VS_N:-0} + 1 ))', - ' if [ "$D_VS_N" -eq 1 ]; then return "${D_VSTATIC:-0}"; else return "${D_VSTATIC2-${D_VSTATIC:-0}}"; fi ;;', + ' static) D_VS_N=$(( ${D_VS_N:-0} + 1 )); return "${D_VSTATIC:-0}" ;;', ' live) D_VL_N=$(( ${D_VL_N:-0} + 1 ))', ' if [ "$D_VL_N" -eq 1 ]; then return "${D_VLIVE:-0}"; else return "${D_VLIVE2-${D_VLIVE:-0}}"; fi ;;', ' esac', @@ -264,13 +273,14 @@ const SCENARIOS = [ ['live-fail', '3xui', ['D_VLIVE=1']], ['no-live-capability', '3xui', ['D_CAPS=file_placement static_verify']], ['install-fail', '3xui', ['D_INSTALL=1']], - ['static-fail', '3xui', ['D_VSTATIC=1', 'D_VSTATIC2=0']], - ['static-unavailable', '3xui', ['D_VSTATIC=2', 'D_VSTATIC2=0']], - ['static-not-applicable', '3xui', ['D_VSTATIC=3', 'D_VSTATIC2=0']], + ['static-fail', '3xui', ['D_VSTATIC=1']], + ['static-unavailable', '3xui', ['D_VSTATIC=2']], + ['static-not-applicable', '3xui', ['D_VSTATIC=3']], ['rollback-restore-fail', '3xui', ['D_INSTALL=1', 'D_RESTORE=1']], - /* Placement fails, so the rollback's static check is the FIRST one this - * scenario makes -- D_VSTATIC, not D_VSTATIC2. */ - ['rollback-verify-fail', '3xui', ['D_INSTALL=1', 'D_VSTATIC=1']], + /* A restore that reports UNAVAILABLE is ALSO a failed rollback. The panel was + * not returned to its recorded state, and the engine must not call that a + * clean rollback merely because the status was not FAILURE. */ + ['rollback-restore-unavailable', '3xui', ['D_INSTALL=1', 'D_RESTORE=2']], ['pasarguard-ok', 'pasarguard', []], ['rebecca-ok', 'rebecca', []], ]; @@ -293,7 +303,7 @@ function runTable() { ' # against ${rest} -- not ${row} -- is what detects that case; comparing', ' # against ${row} never matches and would export the PANEL as a variable.', ' if [ "$assigns" = "$rest" ]; then assigns=""; fi', - ' unset D_DETECT D_CAPS D_CAPS_RC D_BACKUP D_INSTALL D_VSTATIC D_VSTATIC2 D_VLIVE D_VLIVE2 D_RESTORE D_SNAP', + ' unset D_DETECT D_CAPS D_CAPS_RC D_BACKUP D_INSTALL D_VSTATIC D_VLIVE D_VLIVE2 D_RESTORE D_SNAP', ' : > "$RT_TXN_LOGFILE"', ' D_VS_N=0; D_VL_N=0', ' if [ -n "$assigns" ]; then', @@ -620,13 +630,37 @@ test('a placement failure after the mutation boundary triggers rollback exactly assert.equal(row.mutated, 1); assert.equal(countCall('install-fail', 'restore'), 1, 'exactly one restore'); assert.equal(E('install-fail').filter((e) => e === 'rollback').length, 1); - /* The FORWARD static check is never reached: placement failed first. The - * rollback's own static check does run, so the distinguishing fact is the - * call immediately following placement. */ + /* The FORWARD static check is never reached: placement failed first, so the + * call immediately following placement is the rollback. */ assert.equal(calls('install-fail')[calls('install-fail').indexOf('install') + 1], 'restore', 'placement failure must go straight to rollback, not to verification'); }); +/* THE REGRESSION GUARD for the 1.3.0 rollback correction. + * + * The engine used to run the FORWARD static check after every rollback and + * treat a non-zero result as a failed rollback. Because rollback restores the + * panel's PREVIOUS selection, that check answers "no" on every correct + * rollback, so a clean rollback was reported as a failure -- and, worse, the + * engine recorded FAILED instead of ROLLED_BACK. The panel layer now verifies + * its own restore, and the engine must NOT re-ask the forward question. + * + * This asserts the absence of the call, not merely its outcome, because the + * outcome is what made the defect invisible: with the doubles below, a second + * static call returning SUCCESS looks harmless, and it is only the call + * sequence that shows the engine asked a question it had no right to ask. */ +test('a rollback never re-runs the forward static verification', () => { + for (const label of ['install-fail', 'static-fail', 'live-fail']) { + const seq = calls(label); + const restoreAt = seq.indexOf('restore'); + assert.ok(restoreAt >= 0, `${label}: the rollback must have run`); + assert.equal(seq.slice(restoreAt).includes('verify:static'), false, + `${label}: the engine must not ask the forward question after a rollback`); + assert.equal(countCall(label, 'verify:static') <= 1, true, + `${label}: static verification has exactly one call site, on the forward path`); + } +}); + test('a rollback whose restore fails is reported, and does NOT trigger another recovery attempt', () => { const row = R('rollback-restore-fail'); assert.equal(row.rc, 1); @@ -637,12 +671,19 @@ test('a rollback whose restore fails is reported, and does NOT trigger another r assert.equal(E('rollback-restore-fail').filter((e) => e === 'rollback-failed').length, 1); }); -test('a rollback whose static verification fails is reported as a rollback failure', () => { - const row = R('rollback-verify-fail'); +test('a rollback whose restore is UNAVAILABLE is reported as a rollback failure', () => { + /* UNAVAILABLE is not FAILURE on the forward path -- there it means "cannot be + * checked here" and must not roll a good change back. On the ROLLBACK path + * the meaning is different: the panel was not returned to its recorded state, + * and "we could not put it back" is not a clean rollback. The engine + * therefore reports it, and the two outcomes stay distinguishable. */ + const row = R('rollback-restore-unavailable'); assert.equal(row.rc, 1); - assert.equal(row.state, 'FAILED'); - assert.equal(countCall('rollback-verify-fail', 'restore'), 1); - assert.equal(E('rollback-verify-fail').filter((e) => e === 'rollback-failed').length, 1); + assert.equal(row.state, 'FAILED', + 'a restore that could not be performed leaves the panel unrestored'); + assert.equal(countCall('rollback-restore-unavailable', 'restore'), 1); + assert.equal(E('rollback-restore-unavailable').filter((e) => e === 'rollback').length, 1); + assert.equal(E('rollback-restore-unavailable').filter((e) => e === 'rollback-failed').length, 1); }); test('a malformed safety snapshot is refused before restore is attempted', () => { @@ -1006,37 +1047,38 @@ test('the user-facing rollback path is unchanged: it still reads the format-1 na }); test('the panels directory holds exactly the authorised adapters', () => { - /* P4 added no adapter. P5A (2026-09-23) adds exactly one, and the claim is - kept PRECISE rather than dropped: a second adapter appearing without a - phase authorising it is still a failure, and the panels with no adapter are - still asserted absent. */ + /* P4 added no adapter; P5A added 3xui; 1.3.0 adds pasarguard and rebecca. + The claim stays PRECISE: an adapter appearing without a release + authorising it is still a failure. */ const files = readdirSync(PANELS_DIR).sort(); - assert.deepEqual(files, ['3xui.sh', 'index.sh', 'interface.sh'], - 'expected the two contract files plus the authorised 3xui adapter'); - for (const name of ['pasarguard.sh', 'rebecca.sh']) { - assert.equal(existsSync(join(PANELS_DIR, name)), false, - `${name} must not exist: no adapter is authorised for it`); - } + assert.deepEqual(files, ['3xui.sh', 'index.sh', 'interface.sh', 'pasarguard.sh', 'rebecca.sh'], + 'expected the two contract files plus the three authorised adapters'); }); test('the registry implements exactly the panels a phase has authorised', () => { /* The registry is the single decision point, so this is where "implemented" - is either true or false for every panel in the enum. A panel with no - implementation must resolve to NOTHING -- never to a stub that reports - success, because a transaction engine cannot detect a fabricated one. */ - const body = [ + is either true or false for every panel in the enum. An adapter that is + absent from the build must resolve to NOTHING -- never to a stub that + reports success, because a transaction engine cannot detect a fabricated + one. */ + const probe = [ 'for p in 3xui pasarguard rebecca; do', ' impl="$(rt_panel_impl_for "$p")"', ' printf "%s|%s\\n" "$p" "${impl:-none}"', 'done', 'exit 0', ].join('\n'); - const r = sh(body); + const r = sh(probe); assert.equal(r.code, 0, r.err); const got = new Map(r.out.split('\n').filter(Boolean).map((l) => l.split('|'))); - assert.equal(got.get('3xui'), '3xui', '3xui must resolve to its real implementation'); - assert.equal(got.get('pasarguard'), 'none', 'pasarguard must resolve to nothing'); - assert.equal(got.get('rebecca'), 'none', 'rebecca must resolve to nothing'); + for (const p of ['3xui', 'pasarguard', 'rebecca']) { + assert.equal(got.get(p), p, `${p} must resolve to its real implementation`); + } + const absent = sh('RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""\n' + probe); + assert.equal(absent.code, 0, absent.err); + const gone = new Map(absent.out.split('\n').filter(Boolean).map((l) => l.split('|'))); + assert.equal(gone.get('pasarguard'), 'none', 'an absent adapter resolves to nothing'); + assert.equal(gone.get('rebecca'), 'none', 'an absent adapter resolves to nothing'); }); test('a transaction against the real interface, with no panel on this host, fails closed', () => { diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs index 3330731..2639094 100644 --- a/tests/installer.test.mjs +++ b/tests/installer.test.mjs @@ -65,6 +65,17 @@ test('json escape neutralises a breakout without touching data', () => assert.equal(sh('rt_json_escape "a & b > c"').out, 'a & b > c'); }); +test('json escape leaves no brace, so branding can never form a template delimiter', () => { + /* The page is parsed as a template on every panel: Go on 3X-UI, Jinja2 on + PasarGuard (unsandboxed: a delimiter there is code execution), pongo2 on + Rebecca. Every { and } becomes a JavaScript escape of itself. */ + for (const name of ['{{ config }}', '{% endautoescape %}{{ 7*7 }}', '{# c #}', '{{ .subTitle }}', '}}{{']) { + const out = sh(`rt_json_escape ${JSON.stringify(name)}`).out; + assert.equal(/[{}]/.test(out), false, `${name} -> ${out}`); + assert.equal(JSON.parse(`"${out}"`), name, 'and JavaScript reads back exactly the original text'); + } +}); + test('support URL validation accepts only frontend-renderable schemes', () => { for (const u of ['https://t.me/x', 'http://a.b', 'tg://resolve?domain=x', 'mailto:a@b.c']) { assert.ok(ok(`rt_validate_support_url ${JSON.stringify(u)}`), u); @@ -238,6 +249,29 @@ test('generation escapes a payload in the service name', () => { assert.match(r.out, /RAW=0/, 'no unescaped inside the block'); }); +/* Found running the suite on a loaded Linux host (1.3.0): the gate checked the + head of the page with `head -c 512 f | grep -qi ''`. grep -q + exits on its first match; head, still writing, dies of SIGPIPE; pipefail + reports the MATCH as a failure. Measured at ~0.7% of calls under load, it + made install, update and design switching refuse a valid page. The doubles + make that interleaving certain: a grep that, reading a pipe, exits at once + (as grep -q does on a match), and a head that writes in two chunks. */ +test('the structural gate cannot be fooled into refusing a valid page by SIGPIPE', () => { + const r = sh( + GEN_SETUP + + 'RG="$(command -v grep)"; RH="$(command -v head)"; RT="$(command -v tail)"; ' + + 'B="$(dirname "$RT_ROOT")/bin"; mkdir -p "$B"; ' + + // grep reading a pipe (no file operand) leaves at once, like grep -q on a match + 'printf \'#!/usr/bin/env bash\\nfor a in "$@"; do [ -f "$a" ] && exec "%s" "$@"; done\\nexit 0\\n\' "$RG" > "$B/grep"; ' + + // head of a file writes in two chunks, so its second write meets a closed pipe + 'printf \'#!/usr/bin/env bash\\nset -o pipefail\\nf="${@: -1}"; n="${2:-512}"\\n"%s" -c 16 "$f"; sleep 0.3; "%s" -c +17 "$f" | "%s" -c $((n-16))\\n\' "$RH" "$RT" "$RH" > "$B/head"; ' + + 'chmod +x "$B/grep" "$B/head"; PATH="$B:$PATH"; ' + + 'rt_validate_template "$RT_DIST" && echo VALID', + ); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /VALID/, 'a valid page is valid however the pipe is scheduled'); +}); + test('the structural gate rejects a template it cannot trust', () => { assert.ok(!ok('printf "tiny" > "$RT_ROOT/t"; rt_validate_template "$RT_ROOT/t"'), 'too small / not a full doc'); @@ -273,6 +307,32 @@ test('backup create captures a validatable snapshot', () => { assert.match(r.out, /HASVER/); }); +/* Found running the suite on Linux (1.3.0 validation): a design switch and an + immediate `rollback --auto` created their backups in the same second. The + names have one-second resolution and `mkdir -p` reused the directory, so the + rollback's own pre-rollback snapshot overwrote the backup it then restored -- + and the "rollback" re-applied the state it was meant to undo. The `date` + double pins the clock to one second for the first two readings; the second + backup must wait for the next second rather than share the first's name. */ +test('two backups in the same second never share a directory', () => { + const r = sh( + GEN_SETUP + + 'B="$(dirname "$RT_ROOT")/bin"; mkdir -p "$B"; C="$(dirname "$RT_ROOT")/clock"; ' + + 'printf \'#!/usr/bin/env bash\\nn=$(cat "%s" 2>/dev/null || echo 0); n=$((n+1)); echo $n > "%s"\\n' + + 'if [ $n -le 2 ]; then echo 20260101T000000Z; else echo 20260101T000001Z; fi\\n\' "$C" "$C" > "$B/date"; ' + + 'chmod +x "$B/date"; PATH="$B:$PATH"; ' + + 'printf "1.3.0\\n" > "$RT_VERSION_FILE"; ' + + 'printf "first" > "$RT_DIST"; A="$(rt_backup_create)"; ' + + 'printf "second" > "$RT_DIST"; Z="$(rt_backup_create)"; ' + + 'echo "A=${A##*/} Z=${Z##*/}"; echo "A holds: $(cat "$A/template.html")"; ' + + 'echo "latest: $(basename "$(rt_backup_latest)")"', + ); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /A=20260101T000000Z__1\.3\.0 Z=20260101T000001Z__1\.3\.0/, 'the second backup gets the next second'); + assert.match(r.out, /A holds: first/, 'the first backup is not overwritten'); + assert.match(r.out, /latest: 20260101T000001Z__1\.3\.0/, 'and the newest is still the newest'); +}); + test('backup selection returns newest first and prune keeps the N newest', () => { const names = [ '20260101T000000Z__0.7.0', @@ -400,8 +460,8 @@ test('restore-from-backup reinstates artifact + VERSION but keeps current config admin's CURRENT branding — restoring stale config would silently undo a rename the admin made after the backup. The template identity is now re-derived from the artifact against the store, so a store entry for the - backed-up design is part of the fixture; with no matching entry the - restore refuses (see the next test). */ + backed-up design is part of the fixture; without a matching entry the + restore falls back to the meta's template= or Row (see next test). */ const r = sh(GEN_SETUP + 'printf "0.8.0\\n" > "$RT_VERSION_FILE"; rt_config_write "OldName" "" "" ""; ' + 'rt_set_dist "$RT_DIST" >/dev/null; ' + @@ -418,18 +478,44 @@ test('restore-from-backup reinstates artifact + VERSION but keeps current config assert.match(r.out, /NAME=NewName/, 'the current admin config is preserved, not reverted'); }); -test('restore-from-backup refuses an artifact the template store cannot identify', () => { - /* Without a store match the restored artifact and the stored selection could - disagree, which is the one state this system must never produce. */ +test('restore-from-backup falls back to Row when no store match and no meta template', () => { + /* A v1.1.0-generated backup has no template= in its meta and its artifact + may not match any entry in the current store. The restore must not refuse — + it defaults to Row so the artifact and the persisted selection always agree. + This is the path that previously hard-failed and blocked rollback from a + fresh install. */ const r = sh(GEN_SETUP + 'printf "0.8.0\\n" > "$RT_VERSION_FILE"; rt_config_write "OldName" "" "" ""; ' + 'rt_set_dist "$RT_DIST" >/dev/null; ' + 'B="$(rt_backup_create)"; ' + 'printf "0.9.0\\n" > "$RT_VERSION_FILE"; rt_config_write "NewName" "https://t.me/x" "" ""; ' + - 'if rt_restore_from_backup "$B" 2>/dev/null; then echo "NO-STORE-ACCEPTED"; else echo "refused"; fi; ' + - 'printf "NAME=%s\\n" "$(rt_config_get_text SERVICE_NAME_B64)"'); - assert.match(r.out, /refused/, 'no store match, no restore'); - assert.match(r.out, /NAME=NewName/, 'and the current config is untouched'); + 'rt_restore_from_backup "$B" >/dev/null && echo RESTORED; ' + + 'printf "VER=%s\\n" "$(cat "$RT_VERSION_FILE")"; ' + + 'printf "NAME=%s\\n" "$(rt_config_get_text SERVICE_NAME_B64)"; ' + + 'printf "TPL=%s\\n" "$(rt_config_get_raw TEMPLATE)"; ' + + 'cmp -s "$RT_DIST" "$B/template.html" && echo "artifact-matches-source"'); + assert.match(r.out, /RESTORED/, 'no store match falls back to Row'); + assert.match(r.out, /VER=0\.8\.0/, 'the backed-up version is reinstated'); + assert.match(r.out, /NAME=NewName/, 'the current admin config is preserved, not reverted'); + assert.match(r.out, /TPL=row/, 'the selection defaults to Row'); + assert.match(r.out, /artifact-matches-source/, 'the restored artifact is the backed-up bytes'); +}); + +test('restore-from-backup ignores an unknown template= in meta and defaults to Row', () => { + /* If the backup's meta records a template id the current release does not + recognise (e.g. a design removed or renamed since the backup was taken), + restoring that id would produce a stale selection. The restore falls back + to Row instead, keeping the artifact and the selection in agreement. */ + const r = sh(GEN_SETUP + + 'printf "0.8.0\\n" > "$RT_VERSION_FILE"; rt_config_write "OldName" "" "" ""; ' + + 'rt_set_dist "$RT_DIST" >/dev/null; ' + + 'B="$(rt_backup_create)"; ' + + 'printf "template=ghost\\n" >> "$B/meta"; ' + + 'printf "0.9.0\\n" > "$RT_VERSION_FILE"; rt_config_write "NewName" "https://t.me/x" "" ""; ' + + 'rt_restore_from_backup "$B" >/dev/null && echo RESTORED; ' + + 'printf "TPL=%s\\n" "$(rt_config_get_raw TEMPLATE)"'); + assert.match(r.out, /RESTORED/, 'unknown meta template does not block restore'); + assert.match(r.out, /TPL=row/, 'an unknown recorded template falls back to Row'); }); test('archive extraction rejects a symlink member even when its name is clean', @@ -507,6 +593,65 @@ test('render smoke classifies a large served page as pass, not a SIGPIPE miss', assert.equal(miss.out, 'fallback', 'a large page missing the marker is a fallback'); }); +/* Found on a real 3X-UI 3.8.5 host (1.3.0 validation): config, update and + rollback print the live check without having located the panel database, so + the check could not build its test URL and reported "skipped (no test URL + available without sqlite3)" on a host that had sqlite3 and a subscription. + The report now locates the database itself. The doubles stand in for sqlite3 + and curl only; the library's own discovery and classification run. */ +test('the live check after config, update or rollback finds the panel database itself', () => { + const r = sh([ + 'B="$(dirname "$RT_ROOT")/bin"; D="$(dirname "$RT_ROOT")/db"; mkdir -p "$B" "$D"', + 'printf "SQLite format 3\\0" > "$D/x-ui.db"', + 'cat > "$B/sqlite3" <<\'EOF\'', + '#!/usr/bin/env bash', + 'case "$2" in', + ' *subPort*) echo 2096 ;;', + ' *subPath*) echo /sub/ ;;', + ' *inbounds*) printf \'{"clients":[{"email":"a","subId":"abc123"}]}\\n\' ;;', + 'esac', + 'EOF', + 'cat > "$B/curl" <<\'EOF\'', + '#!/usr/bin/env bash', + 'for a in "$@"; do case "$a" in http://127.0.0.1:2096/sub/abc123) printf \'
\'; exit 0 ;; esac; done', + 'exit 7', + 'EOF', + 'chmod +x "$B/sqlite3" "$B/curl"; PATH="$B:$PATH"', + 'RT_ACTIVE_PANEL=3xui; XUI_DB_FOLDER="$D"; unset RT_XUI_DB RT_SMOKE_URL', + 'rt_render_report', + ].join('\n')); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /Live check: a browser request renders Row-Template\./); + assert.doesNotMatch(r.out, /skipped/); +}); + +/* Found on the same host: activation restarts 3X-UI and its subscription server + binds a few seconds after the unit is active, so the check made right after a + fresh install warned "could not reach". The check now waits, but ONLY when the + unit really just started -- an endpoint that is simply down must still be + reported at once. */ +test('the live check waits for a just-restarted panel, and only for one', () => { + const fake = (since) => [ + 'B="$(dirname "$RT_ROOT")/bin"; mkdir -p "$B"', + 'cat > "$B/systemctl" < { /* Regression: rt_detect_xui matched the unit with `systemctl list-unit-files @@ -675,14 +820,18 @@ function writeArtifact(dir, html, sha) { } /* Run a snippet against a Node-prepared install root. `prepare` receives the - POSIX-style root path before bash starts. */ + POSIX-style root path before bash starts. No release source is reachable: + the manager, config and verify complete an incomplete install by + downloading, and a test must never reach the network. A test that needs a + payload redefines rt_fetch_release in its body. */ function shRoot(body, { input, prepare, env } = {}) { const root = mkdtempSync(join(tmpdir(), 'row-t-')).replace(/\\/g, '/'); try { if (prepare) prepare(root); const r = spawnSync( 'bash', - ['-c', 'set -Eeuo pipefail\nexport RT_ROOT="' + root + '"\nsource installer/lib/row-template.sh\n' + body], + ['-c', 'set -Eeuo pipefail\nexport RT_ROOT="' + root + '"\nsource installer/lib/row-template.sh\n' + + 'rt_fetch_release(){ return 1; }\n' + body], { cwd: ROOT, encoding: 'utf8', input, env: env ? { ...process.env, ...env } : undefined }, ); if (r.error) throw r.error; @@ -743,11 +892,16 @@ function writePayload(root, { withStore = true } = {}) { } } +/* No release source is reachable unless a test provides one: verify, config + and the manager complete an incomplete install by downloading, and a test + must never reach the network. A test that needs a payload redefines + rt_fetch_release after these stubs. */ const FLOW_STUBS = [ 'rt_require_root(){ :; }', 'rt_detect_xui(){ return 1; }', 'rt_detect_xui_version(){ return 1; }', 'rt_detect_xui_db(){ return 1; }', + 'rt_fetch_release(){ return 1; }', '', ].join('\n'); @@ -987,6 +1141,44 @@ test('an Editorial backup rolls a Row install forward, and a legacy v1.1.0 backu assert.match(r.out, /live=row/); }); +/* Found rolling a real 3X-UI 3.8.5 host back to the backup its 1.1.0 install + left: the backup's page is 1.1.0's own build, byte-identical to no design in + the 1.3.0 store. The restore selected Row but kept those bytes, so verify + then failed ("canonical artifact does not match the selected template") and + the install could not be switched or updated cleanly. The selected design is + now restored FROM THE STORE, so selection, artifact and store agree; the + backup's VERSION and the admin's current branding are handled as before. */ +test('a backup whose page matches no installed design is restored from the store, consistently', () => { + const r = shRoot( + 'rt_switch_template editorial\n' + + 'legacy="$RT_BACKUPS/20260101T000000Z__1.1.0"\n' + + 'mkdir -p "$legacy"\n' + + // a structurally valid page that is not byte-identical to any store design + 'sed "s#&#" "$RT_TEMPLATE_STORE/row/template.html" > "$legacy/template.html"\n' + + 'rt_sha256 "$legacy/template.html" > "$legacy/template.html.sha256"\n' + + 'printf "1.1.0\\n" > "$legacy/VERSION"\n' + + 'printf "version=1.1.0\\n" > "$legacy/meta"\n' + + '[ -z "$(rt_template_id_for_artifact "$legacy/template.html")" ] && echo "legacy-is-unknown"\n' + + 'rt_restore_from_backup "$legacy" && rt_activate && echo RESTORED\n' + + 'printf "tpl=%s\\n" "$(rt_config_get_raw TEMPLATE)"\n' + + 'printf "id=%s\\n" "$(rt_template_id_for_artifact "$RT_DIST")"\n' + + 'cmp -s "$RT_DIST" "$RT_TEMPLATE_STORE/row/template.html" && echo "canonical-is-store-row"\n' + + 'printf "ver=%s\\n" "$(cat "$RT_VERSION_FILE")"\n' + + 'printf "name=%s\\n" "$(rt_config_get_text SERVICE_NAME_B64)"\n' + + 'grep -q "data-template" "$RT_LIVE" && echo "live=not-row" || echo "live=row"', + { prepare: prepareInstall }, + ); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /legacy-is-unknown/, 'the fixture really is a page no design matches'); + assert.match(r.out, /RESTORED/); + assert.match(r.out, /tpl=row/, 'the selection is Row'); + assert.match(r.out, /id=row/, 'and the canonical artifact is identified as Row'); + assert.match(r.out, /canonical-is-store-row/, 'because it IS the installed Row design'); + assert.match(r.out, /ver=1\.1\.0/, 'the backed-up VERSION is reinstated, as for any backup'); + assert.match(r.out, /name=Test VPN/, 'the current branding is kept'); + assert.match(r.out, /live=row/); +}); + test('a corrupt or mismatched backup is refused before anything is restored', () => { const r = shRoot( 'rt_switch_template editorial\n' + diff --git a/tests/panel-support.test.mjs b/tests/panel-support.test.mjs new file mode 100644 index 0000000..73e3cf2 --- /dev/null +++ b/tests/panel-support.test.mjs @@ -0,0 +1,310 @@ +/* What each panel's support status really is, and that the documentation says + * exactly that. + * + * The release packages a page shell for every panel in the registry (3X-UI, + * PasarGuard, Rebecca), and RT_PANEL_IDS names all three. Neither makes a panel + * supported. Support means the installer can find the panel, install onto it, + * activate, verify and roll back -- so the status is read from the installer + * itself, and every README and compatibility page is checked against it: + * + * - the panel registry (installer/panels/index.sh) is asked which panels have + * an implementation, and every panel operation is called on a host where + * the panel is NOT installed, where none may report success; + * - the install command is run on a host where a panel is only half there + * (one detection signal), with real detection, to show it refuses; + * - the capability matrix in docs/.../compatibility.mdx (English, Persian, + * Arabic) and the panel table in all five READMEs must match those results. + * + * Since 1.3.0 all three panels have an implementation, so all three are + * Supported; the full install/activate/verify/rollback/uninstall behaviour of + * PasarGuard and Rebecca is exercised in tests/installer-panel-pasarguard.test.mjs + * and tests/installer-panel-rebecca.test.mjs. + * + * A panel becomes "Supported" in the docs only when this file, unchanged, finds + * an implementation for it. Nothing here is satisfied by an adapter file merely + * existing. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync, copyFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildablePanelIds } from '../tools/panels.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const read = (p) => readFileSync(join(ROOT, p), 'utf8'); + +function bash(body, env = {}) { + const r = spawnSync('bash', ['-c', 'set -Eeuo pipefail\n' + body], { + cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...env }, + }); + if (r.error) throw r.error; + return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; +} + +/* --- what the installer can do, per panel ----------------------------------- */ + +/* The operations a column of the matrix stands for. Each is a public panel + operation from installer/panels/interface.sh. */ +const VERBS = { + detection: ['detect'], + install: ['install_template', '/nonexistent/src'], + verification: ['verify', 'static'], + backup: ['backup_state'], + restore: ['restore_state', '/nonexistent/snap'], + uninstall: ['uninstall_template'], +}; + +/* Activation is NOT one verb, so it is not checked by a return code. It is the + panel-side act of SELECTING the page, and the frozen P3 vocabulary names the + mechanisms that may perform it. A panel can be activated only when its + adapter declares at least one of them, so "Activate" is a capability question + and is asked as one. Claiming activation without a mechanism would be a + matrix cell with nothing behind it. */ +const ACTIVATION_TOKENS = ['selection_write', 'env_activation', 'db_activation']; + +/* Ask the installer. For every panel id: the implementation the registry + resolves to, and the return code of every operation above. */ +function installerMatrix() { + const lines = [ + 'export RT_ROOT="$(mktemp -d)/rt"; trap \'rm -rf "$(dirname "$RT_ROOT")"\' EXIT', + 'mkdir -p "$RT_ROOT"', + '. installer/lib/row-template.sh', + 'echo "ids=$RT_PANEL_IDS"', + 'for p in $RT_PANEL_IDS; do', + ' echo "impl-$p=$(rt_panel_impl_for "$p")"', + ]; + for (const [col, [verb, ...args]] of Object.entries(VERBS)) { + lines.push(` rc=0; rt_panel_${verb} "$p" ${args.join(' ')} >/dev/null 2>&1 || rc=$?; echo "${col}-$p=$rc"`); + } + lines.push(' echo "caps-$p=$(rt_panel_capabilities "$p" 2>/dev/null | tr \'\\n\' \' \')"'); + lines.push('done'); + const r = bash(lines.join('\n')); + assert.equal(r.code, 0, r.err); + const kv = Object.fromEntries(r.out.split('\n').map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])); + const ids = kv.ids.split(' ').filter(Boolean); + return Object.fromEntries(ids.map((p) => [p, { + implemented: kv[`impl-${p}`] !== '', + caps: (kv[`caps-${p}`] || '').split(' ').filter(Boolean), + rc: Object.fromEntries(Object.keys(VERBS).map((c) => [c, Number(kv[`${c}-${p}`])])), + }])); +} + +/* The seven capabilities the status column stands for, in matrix order. A panel + is Supported only when every one of them is present -- which is what makes + "Supported" a claim about behaviour rather than about a file existing. */ +const CAPABILITY_COLUMNS = ['detection', 'install', 'activation', 'verification', 'backup', 'restore', 'uninstall']; + +const MATRIX = installerMatrix(); +const INSTALLABLE = Object.keys(MATRIX).filter((p) => MATRIX[p].implemented); +const UNAVAILABLE = 2; + +test('the installer implements all three panels', () => { + assert.deepEqual(Object.keys(MATRIX).sort(), ['3xui', 'pasarguard', 'rebecca'], 'the closed panel set'); + assert.deepEqual(INSTALLABLE.sort(), ['3xui', 'pasarguard', 'rebecca'], + 'every panel in the registry has an installer implementation'); + assert.deepEqual(buildablePanelIds().sort(), ['3xui', 'pasarguard', 'rebecca'], + 'and a page shell is built for each'); +}); + +test('every implemented panel declares a way to activate', () => { + for (const p of INSTALLABLE) { + const mechanisms = MATRIX[p].caps.filter((c) => ACTIVATION_TOKENS.includes(c)); + assert.ok(mechanisms.length >= 1, + `${p}: activation needs a declared mechanism (one of ${ACTIVATION_TOKENS.join(', ')}), ` + + `but the adapter declares [${MATRIX[p].caps.join(', ')}]`); + } +}); + +/* Does the installer REALLY have all seven capabilities for PANEL? Activation + is answered from the declared mechanism (there is no activation verb); + everything else must have a real operation, which VERBS enumerates. */ +function hasAllCapabilities(p) { + if (!INSTALLABLE.includes(p)) return false; + return CAPABILITY_COLUMNS.every((col) => (col === 'activation' + ? MATRIX[p].caps.some((c) => ACTIVATION_TOKENS.includes(c)) + : Object.prototype.hasOwnProperty.call(MATRIX[p].rc, col))); +} + +test('on a host without the panel, no operation reports success', () => { + /* This test host runs none of the panels. An operation that answered + SUCCESS here would be claiming work it could not have done. Detection must + say the panel is not here (NOT_APPLICABLE); everything else must refuse. */ + const HAS = { '3xui': ['/usr/local/x-ui/x-ui', '/usr/local/bin/x-ui'], + pasarguard: ['/opt/pasarguard/.env'], rebecca: ['/opt/rebecca/.env'] }; + for (const [p, paths] of Object.entries(HAS)) { + if (paths.some((x) => existsSync(x))) continue; // a real panel host: not this test's subject + assert.equal(MATRIX[p].rc.detection, 3, `${p}: detection must be NOT_APPLICABLE (3)`); + for (const [col, rc] of Object.entries(MATRIX[p].rc)) { + assert.notEqual(rc, 0, `${p}: ${col} must not report SUCCESS on a host without the panel`); + } + } +}); + +/* A host where PasarGuard or Rebecca is only HALF there: the panel's systemd + unit is registered, and nothing else (no .env, no data directory, no CLI). + One signal is not identification (installer/panels/interface.sh), so install + must refuse, write nothing, and say why. Detection runs for real, against a + stand-in systemctl on PATH. Skipped where a real panel is installed, since + the library looks at fixed system paths. */ +const HAS_REAL_PANEL = ['/usr/local/x-ui/x-ui', '/usr/local/bin/x-ui', '/opt/pasarguard', '/opt/rebecca', + '/var/lib/pasarguard', '/var/lib/rebecca'].some((p) => existsSync(p)); + +test('on a host where PasarGuard or Rebecca is only half there, install refuses and writes nothing', { skip: HAS_REAL_PANEL }, () => { + for (const unit of ['pasarguard.service', 'rebecca.service']) { + const base = mkdtempSync(join(tmpdir(), 'row-panel-')); + try { + const bin = join(base, 'bin'); + mkdirSync(bin); + writeFileSync(join(bin, 'systemctl'), `#!/bin/sh\nprintf '%s\\n' '${unit} enabled enabled'\n`); + chmodSync(join(bin, 'systemctl'), 0o755); + const payload = join(base, 'payload'); + mkdirSync(join(payload, 'shells', 'pasarguard', 'row'), { recursive: true }); + mkdirSync(join(payload, 'shells', 'rebecca', 'row'), { recursive: true }); + copyFileSync(join(ROOT, 'template', 'index.html'), join(payload, 'template.html')); + writeFileSync(join(payload, 'VERSION'), read('VERSION')); + writeFileSync(join(payload, 'shells', 'pasarguard', 'row', 'shell.html'), '{{ user.username }}\n'); + writeFileSync(join(payload, 'shells', 'rebecca', 'row', 'shell.html'), '{{ user.username }}\n'); + + const r = bash([ + `export PATH="${bin}:$PATH" RT_ROOT="${base}/rt" RT_BIN="${base}/row-template"`, + '. installer/lib/row-template.sh', + 'rt_require_root(){ :; }', + `RT_ASSUME_YES=1 rt_cmd_install "${payload}" c.trim()); + if (cells.length !== width) continue; + const id = panelIdOf(cells[0]); + if (id) rows[id] = cells; + } + return rows; +} + +/* The capability matrix. Columns 1-7 are the seven capabilities in + CAPABILITY_COLUMNS order, 8 is the page shell, 9 the status -- in every + language. A panel may be marked Supported only when ALL SEVEN are present, so + "Supported" cannot be claimed on a partial implementation. */ +const COMPAT = { + en: { file: 'docs/src/content/docs/compatibility.mdx', research: 'Research' }, + fa: { file: 'docs/src/content/docs/fa/compatibility.mdx', research: 'پژوهش' }, + ar: { file: 'docs/src/content/docs/ar/compatibility.mdx', research: 'بحث' }, +}; + +for (const [lang, { file, research }] of Object.entries(COMPAT)) { + test(`the ${lang} compatibility matrix matches what the installer can do`, () => { + const rows = tableRows(read(file), 10); + assert.deepEqual(Object.keys(rows).sort(), Object.keys(MATRIX).sort(), `${file}: one matrix row per panel`); + for (const [p, cells] of Object.entries(rows)) { + const supported = hasAllCapabilities(p); + CAPABILITY_COLUMNS.forEach((col, i) => { + assert.equal(cells[i + 1], supported ? '✅' : '❌', + `${file}: ${p} ${col} must be ${supported ? '✅' : '❌'} -- the installer ` + + `${supported ? 'implements' : 'does not implement'} it`); + }); + assert.equal(cells[8], buildablePanelIds().includes(p) ? '✅' : '❌', `${file}: ${p} page shell`); + if (supported) { + assert.ok(cells[9].startsWith('**'), `${file}: ${p} is marked supported`); + } else { + assert.equal(cells[9].includes('**'), false, `${file}: ${p} must not be marked supported`); + assert.ok(cells[9].includes(research), `${file}: ${p} is marked as research`); + } + } + }); +} + +const READMES = ['README.md', 'README.fa.md', 'README.ar.md', 'README.ru.md', 'README.zh-CN.md']; + +test('every README marks only installable panels as supported', () => { + for (const file of READMES) { + const rows = tableRows(read(file), 3); + assert.deepEqual(Object.keys(rows).sort(), Object.keys(MATRIX).sort(), `${file}: one row per panel`); + for (const [p, cells] of Object.entries(rows)) { + if (INSTALLABLE.includes(p)) { + assert.ok(cells[1].includes('✅'), `${file}: ${p} is supported`); + } else { + assert.equal(cells[1].includes('✅'), false, `${file}: ${p} must not be marked supported`); + assert.ok(cells[1].includes('🔬'), `${file}: ${p} is marked as research`); + } + } + } +}); + +/* The changelog is history: every release's section must describe the panels + as they were IN THAT RELEASE. Before 1.3.0 no release supported PasarGuard or + Rebecca, so no older section may call them supported; from the release that + implements a panel on, its section may -- and only because the installer, + asked above, really implements it. */ +function changelogSections() { + const out = []; + let cur = null; + for (const line of read('CHANGELOG.md').split('\n')) { + const m = line.match(/^## \[?(\d+\.\d+\.\d+)\]?/); + if (m) { cur = { version: m[1], text: '' }; out.push(cur); continue; } + if (cur) cur.text += `${line}\n`; + } + return out; +} + +const semver = (v) => v.split('.').map(Number); +const before = (a, b) => { + const [x, y] = [semver(a), semver(b)]; + for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] < y[i]; + return false; +}; + +test('the changelog calls PasarGuard or Rebecca supported only from the release that implements them', () => { + const sections = changelogSections(); + assert.ok(sections.some((s) => s.version === '1.3.0'), 'the 1.3.0 section exists'); + for (const { version, text } of sections) { + const sentences = text.replace(/\n\s*/g, ' ').split(/(?<=[.!?])\s+/); + for (const s of sentences) { + if (!/PasarGuard|Rebecca/.test(s) || !/\bsupported\b/i.test(s)) continue; + if (before(version, '1.3.0')) { + assert.match(s, /\bnot (?:yet )?supported\b|\bunsupported\b|not supported panels/i, + `${version}: a changelog sentence names PasarGuard/Rebecca as supported before 1.3.0: "${s}"`); + } else { + for (const p of ['pasarguard', 'rebecca']) { + if (new RegExp(p, 'i').test(s) && !/\bnot (?:yet )?supported\b|\bunsupported\b/i.test(s)) { + assert.ok(INSTALLABLE.includes(p), `${version}: claims ${p} is supported, but the installer does not implement it`); + } + } + } + } + } + const current = sections.find((s) => s.version === '1.3.0').text; + assert.match(current, /PasarGuard/, 'the 1.3.0 section names PasarGuard'); + assert.match(current, /Rebecca/, 'the 1.3.0 section names Rebecca'); +}); diff --git a/tests/panels-engines.test.mjs b/tests/panels-engines.test.mjs new file mode 100644 index 0000000..f6b0bc7 --- /dev/null +++ b/tests/panels-engines.test.mjs @@ -0,0 +1,331 @@ +/* The PasarGuard and Rebecca pages, rendered by the REAL template engines. + * + * Every other panel test renders the shells with a test-only stand-in. This + * file renders the SHIPPED shells -- prelude, autoescape block and all -- with + * real Jinja2 configured the way PasarGuard configures it, and real pongo2 + * v6.1.0 configured the way Rebecca configures it (tools/engines.mjs), from the + * page context each panel really builds. It is the evidence behind "Supported": + * + * - every design parses and renders on both engines; + * - the island a subscriber receives carries exactly the figures the panel + * holds, for every fixture, including the states that were refused before + * (on_hold) and the ones the old fixtures never used (limited, expired); + * - the time conversions are exact and timezone-independent, including + * Rebecca's zoneless online_at, converted in pongo2 integer arithmetic; + * - hostile panel data (a username, a link remark, an announcement) never + * becomes markup or template code on the page. On PasarGuard this is the + * shell's own doing: its Jinja2 environment does not autoescape. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +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 { templateIds } from '../tools/templates.mjs'; +import { + renderPasarGuard, renderRebecca, pasarguardContext, rebeccaContext, +} from '../tools/engines.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function fixtures(panel) { + const dir = join(ROOT, 'tests', 'fixtures', 'panels', panel); + return readdirSync(dir).filter((f) => f.endsWith('.json')).sort() + .map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8'))); +} + +const PG = fixtures('pasarguard'); +const RB = fixtures('rebecca'); +const SHELL = { + pasarguard: assembleShell('pasarguard', 'row').html, + rebecca: assembleShell('rebecca', 'row').html, +}; + +const LINKS = [ + 'vless://11111111-1111-1111-1111-111111111111@203.0.113.10:443?security=reality&type=tcp#DE', + 'trojan://secret@203.0.113.11:443?sni=example.com#NL', +]; + +const HOSTILE = '">{{ 7*7 }}{% raw %}{# c #}\''; + +/* The page model a fixture should produce. The panels' page contexts carry + less than their /info payloads, and the adapters' header-derived fields are + replaced by what the page can actually see. */ +function pgExpected(doc, links) { + return { + ...doc.expected.model, + title: '', // not in PasarGuard's page context + subUrl: '', // not a database column: the page uses its own URL + subClashUrl: '', + links, + }; +} + +function rbExpected(doc, links) { + const subUrl = doc.native.info.subscription_url || ''; + return { + ...doc.expected.model, + title: '', // not in Rebecca's page context + announce: '', // Rebecca has no announcement + subUrl, + subClashUrl: subUrl ? `${subUrl}/clash-meta` : '', + links, + }; +} + +/* --- every design renders on both engines ----------------------------------- */ + +test('every design renders on PasarGuard\'s Jinja2 to a contract-valid island', () => { + const doc = PG.find((d) => d.case === '00-showcase'); + const ids = templateIds(); + const pages = renderPasarGuard(ids.map((id) => ({ + html: assembleShell('pasarguard', id).html, + context: pasarguardContext(doc, { links: LINKS }), + }))); + pages.forEach((html, i) => { + const m = toModel(extractIsland(html)); + assert.deepEqual(m, pgExpected(doc, LINKS), `${ids[i]}: the island carries the panel's figures`); + assert.ok(html.startsWith(''), `${ids[i]}: the page starts with the doctype`); + assert.ok(html.trimEnd().endsWith(''), `${ids[i]}: and ends with `); + assert.equal(TEMPLATE_DELIMITER.test(html.replace(/\{\{ 7\*7 \}\}/g, '')), false, + `${ids[i]}: no template syntax is left in the served page`); + }); +}); + +test('every design renders on Rebecca\'s pongo2 to a contract-valid island', () => { + const doc = RB.find((d) => d.case === '00-showcase'); + const ids = templateIds(); + const pages = renderRebecca(ids.map((id) => ({ + html: assembleShell('rebecca', id).html, + context: rebeccaContext(doc, { links: LINKS }), + }))); + pages.forEach((html, i) => { + const m = toModel(extractIsland(html)); + assert.deepEqual(m, rbExpected(doc, LINKS), `${ids[i]}: the island carries the panel's figures`); + assert.ok(html.startsWith(''), `${ids[i]}: the page starts with the doctype`); + assert.equal(TEMPLATE_DELIMITER.test(html), false, `${ids[i]}: no template syntax is left in the served page`); + }); +}); + +/* --- every fixture ---------------------------------------------------------- */ + +test('PasarGuard: every fixture renders exactly the model its adapter produces', () => { + const docs = PG.filter((d) => d.expected.model !== null); + const pages = renderPasarGuard(docs.map((d) => ({ html: SHELL.pasarguard, context: pasarguardContext(d) }))); + pages.forEach((html, i) => { + assert.deepEqual(toModel(extractIsland(html)), pgExpected(docs[i], []), docs[i].case); + }); + assert.ok(docs.some((d) => d.case === '08-on-hold'), 'on_hold is among them, no longer refused'); +}); + +test('Rebecca: every fixture renders exactly the model its adapter produces', () => { + const docs = RB.filter((d) => d.expected.model !== null); + const pages = renderRebecca(docs.map((d) => ({ html: SHELL.rebecca, context: rebeccaContext(d) }))); + pages.forEach((html, i) => { + assert.deepEqual(toModel(extractIsland(html)), rbExpected(docs[i], []), docs[i].case); + }); + assert.ok(docs.some((d) => d.case === '08-on-hold'), 'on_hold is among them, no longer refused'); +}); + +/* --- the states that matter ------------------------------------------------- */ + +test('PasarGuard on_hold: enabled, and the clock starts on first connection for the hold duration', () => { + const doc = PG.find((d) => d.case === '08-on-hold'); + const [html] = renderPasarGuard([{ html: SHELL.pasarguard, context: pasarguardContext(doc) }]); + const m = toModel(extractIsland(html)); + assert.equal(m.enabled, true); + assert.equal(m.expire, -doc.native.info.on_hold_expire_duration, 'expire is the negative hold duration'); + assert.match(html, /Starts on first connection/, 'and the no-script text says so'); +}); + +test('on_hold without a known duration is unknown, never "never expires"', () => { + const pg = PG.find((d) => d.case === '22-on-hold-no-duration'); + const rb = RB.find((d) => d.case === '08-on-hold'); + const [pgHtml] = renderPasarGuard([{ html: SHELL.pasarguard, context: pasarguardContext(pg) }]); + const [rbHtml] = renderRebecca([{ html: SHELL.rebecca, context: rebeccaContext(rb) }]); + for (const [name, html] of [['PasarGuard', pgHtml], ['Rebecca', rbHtml]]) { + const m = toModel(extractIsland(html)); + assert.equal(m.enabled, true, `${name}: on_hold is enabled`); + assert.equal(m.expire, null, `${name}: the expiry is unknown`); + assert.doesNotMatch(html, /id="expiry-value">Never expires/, `${name}: and is never shown as "never"`); + } +}); + +test('PasarGuard: the limited and expired statuses stay enabled and keep their figures', () => { + const docs = PG.filter((d) => ['20-status-limited', '21-status-expired'].includes(d.case)); + const pages = renderPasarGuard(docs.map((d) => ({ html: SHELL.pasarguard, context: pasarguardContext(d) }))); + pages.forEach((html, i) => { + const m = toModel(extractIsland(html)); + assert.equal(m.enabled, true, `${docs[i].case}: enabled`); + assert.deepEqual(m, pgExpected(docs[i], []), docs[i].case); + }); +}); + +test('Rebecca: a status the panel does not know renders disabled, as Rebecca classes it', () => { + const doc = RB.find((d) => d.case === '17-unknown-status'); + const [html] = renderRebecca([{ html: SHELL.rebecca, context: rebeccaContext(doc) }]); + const m = toModel(extractIsland(html)); + assert.equal(m.enabled, false); + assert.equal(m.online, false, 'and a disabled subscription is never online'); +}); + +/* --- time ------------------------------------------------------------------- */ + +test('PasarGuard: expire from a zoneless database datetime matches the panel\'s own header', () => { + /* PasarGuard's subscription-userinfo header is int(expire.timestamp()), and + the panel runs in UTC. A naive datetime read under TZ=UTC must give the + same second the header reports. */ + const doc = PG.find((d) => d.case === '01-active-online'); + const ctx = pasarguardContext(doc); + ctx.user.expire = '2026-11-02T12:00:00'; + ctx.user.expire_naive = true; + const [html] = renderPasarGuard([{ html: SHELL.pasarguard, context: ctx }], { TZ: 'UTC' }); + assert.equal(toModel(extractIsland(html)).expire, 1793620800); +}); + +test('PasarGuard: online is true inside 120 s of now() and false outside it', () => { + const doc = PG.find((d) => d.case === '01-active-online'); + const seen = Date.parse(doc.native.info.online_at) / 1000; + const cases = [[0, true], [120, true], [121, false], [-5, false]]; + const pages = renderPasarGuard(cases.map(([age]) => ({ + html: SHELL.pasarguard, context: pasarguardContext(doc, { now: seen + age }), + }))); + pages.forEach((html, i) => { + assert.equal(toModel(extractIsland(html)).online, cases[i][1], `age ${cases[i][0]} s`); + }); +}); + +/* Rebecca's online_at arrives without a zone and is UTC. pongo2 cannot parse a + date, so the prelude converts the civil date with integer arithmetic. Every + instant here goes through the SHIPPED prelude on the real engine. */ +function rebeccaTimeProbe(onlineAt, now) { + const body = '\n

{{ lastOnline }}|{% if isOnline %}1{% else %}0{% endif %}

\n\n'; + return { + html: wrapForPanel('rebecca', 'pongo2', body), + context: { + user: { status: 'active', status_class: 'active', used_traffic: 0, online_at: onlineAt, subscription_url: '' }, + links: [], support_url: '', current_timestamp: now, + }, + }; +} + +function probeResult(html) { + const m = html.match(/

([^|]*)\|([01])<\/p>/); + assert.ok(m, 'the probe rendered'); + return { lastOnline: m[1] === '' ? null : Number(m[1]), online: m[2] === '1' }; +} + +test('Rebecca: online_at converts to the exact UTC millisecond for every date form', () => { + /* A deterministic spread: every month boundary, leap days, both centuries. */ + const instants = []; + let seed = 20260918; + const rand = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; }; + for (let i = 0; i < 400; i += 1) instants.push(Math.floor(rand() * 4102444800)); // 1970..2100 + for (const s of ['1970-01-01', '2000-02-29', '2024-02-29', '2024-03-01', '2100-02-28', '2026-12-31']) { + instants.push(Date.parse(`${s}T23:59:59Z`) / 1000); + } + const pad = (n, w = 2) => String(n).padStart(w, '0'); + const forms = (sec) => { + const d = new Date(sec * 1000); + const civil = `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; + const clock = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`; + return [`${civil} ${clock}`, `${civil}T${clock}Z`, `${civil}T${clock}.123456789Z`, `${civil} ${clock}.5`]; + }; + const jobs = []; + const want = []; + for (const sec of instants) { + for (const f of forms(sec)) { + jobs.push(rebeccaTimeProbe(f, sec + 30)); + want.push(sec * 1000); + } + } + const pages = renderRebecca(jobs, { TZ: 'Asia/Tehran' }); + pages.forEach((html, i) => { + const r = probeResult(html); + assert.equal(r.lastOnline, want[i], `instant ${want[i]}: ${jobs[i].context.user.online_at}`); + assert.equal(r.online, true, 'thirty seconds ago is online'); + }); +}); + +test('Rebecca: the online window and a value with a foreign offset', () => { + const at = '2026-09-18 11:58:30'; + const sec = Date.parse('2026-09-18T11:58:30Z') / 1000; + const cases = [ + [at, sec + 120, sec * 1000, true], + [at, sec + 121, sec * 1000, false], + [at, sec - 1, sec * 1000, false], // a clock behind the record is not "online" + ['2026-09-18T11:58:30+03:30', sec, null, false], // a non-UTC offset is not guessed + ['garbage', sec, null, false], + [null, sec, null, false], + ]; + const pages = renderRebecca(cases.map(([v, now]) => rebeccaTimeProbe(v, now))); + pages.forEach((html, i) => { + const r = probeResult(html); + assert.equal(r.lastOnline, cases[i][2], `lastOnline for ${cases[i][0]}`); + assert.equal(r.online, cases[i][3], `online for ${cases[i][0]} at +${cases[i][1] - sec}s`); + }); +}); + +/* --- hostile data ----------------------------------------------------------- */ + +function hostileContexts() { + const pgDoc = PG.find((d) => d.case === '01-active-online'); + const rbDoc = RB.find((d) => d.case === '01-active-online'); + const pg = pasarguardContext(pgDoc, { links: [`vless://x@h:1#${HOSTILE}`] }); + pg.user.username = HOSTILE; + pg.user.admin = { support_url: `https://t.me/${HOSTILE}` }; + pg.announce = `Maintenance ${HOSTILE}`; + const rb = rebeccaContext(rbDoc, { links: [`ss://x@h:1#${HOSTILE}`] }); + rb.user.username = HOSTILE; + rb.user.subscription_url = `https://sub.example.com/sub/${HOSTILE}`; + rb.support_url = `https://t.me/${HOSTILE}`; + return { pg, rb }; +} + +test('hostile panel data never becomes markup or template code on either page', () => { + const { pg, rb } = hostileContexts(); + const benignPg = pasarguardContext(PG.find((d) => d.case === '01-active-online')); + const benignRb = rebeccaContext(RB.find((d) => d.case === '01-active-online')); + const [pgBad, pgGood] = renderPasarGuard([ + { html: SHELL.pasarguard, context: pg }, { html: SHELL.pasarguard, context: benignPg }]); + const [rbBad, rbGood] = renderRebecca([ + { html: SHELL.rebecca, context: rb }, { html: SHELL.rebecca, context: benignRb }]); + const count = (html, re) => (html.match(re) || []).length; + for (const [name, bad, good] of [['PasarGuard', pgBad, pgGood], ['Rebecca', rbBad, rbGood]]) { + assert.equal(count(bad, /