diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 484f5670..217d6990 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**194 classes** across **32 capabilities** · **19 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**195 classes** across **32 capabilities** · **19 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -124,6 +124,7 @@ - **Tiger_Module_Discovery** `@api` — find the modules present on disk (active or not). · `library/Tiger/Module/Discovery.php` - **Tiger_Module_Github** `@api` — read public GitHub repos over cURL (no auth, public only). · `library/Tiger/Module/Github.php` - **Tiger_Module_Installer** `@api` — install / update / remove modules from public GitHub repos. · `library/Tiger/Module/Installer.php` +- **Tiger_Module_Longform** `@api` — resolves a module listing's LONG-FORM copy and renders it safely. · `library/Tiger/Module/Longform.php` - **Tiger_Module_Pricing** `@api` — the manifest `pricing` block, normalized. · `library/Tiger/Module/Pricing.php` - **Tiger_Module_Registry** `@api` — the client for the module catalog, now **multi-source**. · `library/Tiger/Module/Registry.php` - **Tiger_Module_Source** `@api` — one catalog feed the Module Manager reads. · `library/Tiger/Module/Source.php` diff --git a/MARKETPLACE.md b/MARKETPLACE.md index e6d58025..14b2ce78 100644 --- a/MARKETPLACE.md +++ b/MARKETPLACE.md @@ -162,6 +162,7 @@ The whole client half is core, free, and **vendor-neutral** — it works against | `Tiger_Crypto_Signature` | Ed25519 keypair / sign / verify / `verifyFile` / `fingerprint` | | `Tiger_License_Checker` | hold the install's license keys, **verify** against a module's declared authority (cached, signed), **`gate()`** auto-update, `remember()` a bought license. Persists in the lazy `option` tier (`Tiger_License_Store`). | | `Tiger_License_Authority` (client) | the client for an authority's `/download` endpoint — get a signed download descriptor `{url, signature, sha256, version}` | +| `Tiger_Module_Longform` | resolve a listing's long-form "plugin page" copy — inline `readme` (a paid module's repo is private, so its marketplace serves the review copy) or a public repo's `tiger_md` URL — and render it through a **safe-mode** markdown parser. The ONE renderer behind both the Module Manager's "View more" and a marketplace's own listing page. | | `Tiger_Module_Installer::installFromAuthority` | fetch the signed download → **verify the signature before extract** → install → `remember()` the license | | `Tiger_Update_Checker` + `System_Service_Updates` | annotate a licensed module's update with its license state, and **refuse applying an update** to a definitively lapsed one | diff --git a/library/Tiger/Module/Longform.php b/library/Tiger/Module/Longform.php new file mode 100644 index 00000000..16e30cdf --- /dev/null +++ b/library/Tiger/Module/Longform.php @@ -0,0 +1,198 @@ +setSafeMode(true); // filters javascript:/data: URLs and unsafe attributes + $parser->setMarkupEscaped(true); // raw HTML in the source is SHOWN, never executed + $parser->setBreaksEnabled(false); + return (string) $parser->text($markdown); + } catch (Throwable $e) { + return ''; + } + } + + /** + * Fetch a `tiger_md` URL, cached on disk. + * + * HTTPS only: a body served over plain http can be rewritten in transit into whatever an attacker + * wants an admin to read about a module they are deciding whether to install. + * + * @param string $url the raw markdown URL + * @return string the markdown ('' on any failure) + */ + public static function fetch($url) + { + $url = (string) $url; + if (stripos($url, 'https://') !== 0) { return ''; } + + // An injected transport replaces the WHOLE fetch path, disk cache included — consulting the + // cache first would make a test depend on whether an earlier run had warmed that URL's file. + if (self::$_transport !== null) { + $body = call_user_func(self::$_transport, $url); + return self::_acceptable($body) ? $body : ''; + } + + $cached = self::_cacheGet($url); + if ($cached !== null) { return $cached; } + + $context = stream_context_create(['http' => [ + 'timeout' => self::TIMEOUT, + // An outbound request with no User-Agent is 403'd by some WAFs (file_get_contents sends + // none by default), which would read as "the vendor is down" for every listing behind one. + 'header' => "User-Agent: Tiger/" . Tiger_Version::VERSION . "\r\n", + 'follow_location' => 1, + 'max_redirects' => 3, + ]]); + $body = @file_get_contents($url, false, $context, 0, self::MAX_BYTES + 1); + + if (!self::_acceptable($body)) { return ''; } + + self::_cachePut($url, $body); + return $body; + } + + /** A fetched body is usable when it is a non-empty string within the size ceiling. */ + protected static function _acceptable($body) + { + return is_string($body) && $body !== '' && strlen($body) <= self::MAX_BYTES; + } + + /** The cached markdown for a URL while it is still fresh, else null. */ + protected static function _cacheGet($url) + { + $file = self::_cacheFile($url); + if ($file && is_file($file) && (time() - filemtime($file)) < self::CACHE_TTL) { + $body = @file_get_contents($file); + if (is_string($body)) { return $body; } + } + return null; + } + + /** Store a fetched body; a failed write just means the next request refetches. */ + protected static function _cachePut($url, $body) + { + $file = self::_cacheFile($url); + if ($file) { @file_put_contents($file, $body); } + } + + /** The on-disk cache path for a URL (hashed — a URL is not a safe filename). */ + protected static function _cacheFile($url) + { + $base = defined('APPLICATION_ROOT') ? rtrim(APPLICATION_ROOT, '/') : rtrim(getcwd(), '/'); + $dir = $base . '/var/cache/longform'; + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { return null; } + return $dir . '/' . sha1($url) . '.md'; + } +} diff --git a/modules/system/services/Modules.php b/modules/system/services/Modules.php index 6e30816f..65212b39 100644 --- a/modules/system/services/Modules.php +++ b/modules/system/services/Modules.php @@ -671,10 +671,7 @@ public function inspect(array $params): void } $tigerMd = Tiger_Module_Github::fetchRaw($r['org'], $r['repo'], $ref, 'TIGER.md'); - $descHtml = ''; - if ($tigerMd !== null) { - try { $descHtml = $this->_scrub((new Tiger_Cms_Renderer())->renderBody($tigerMd, 'markdown')); } catch (Throwable $e) {} - } + $descHtml = $tigerMd !== null ? Tiger_Module_Longform::render($tigerMd) : ''; // "Installed" = recorded by the installer OR simply present on disk (discovered) — the // latter covers a theme/module placed manually or activated without an installer row. @@ -721,11 +718,10 @@ protected function _inspectMarketplace(string $slug, string $source): void $listing = Tiger_Module_Registry::listing($slug, $source); if (!$listing) { $this->_error('system.error.listing_gone'); return; } - $descHtml = ''; - $readme = (string) ($listing['readme'] ?? ''); - if ($readme !== '') { - try { $descHtml = $this->_scrub((new Tiger_Cms_Renderer())->renderBody($readme, 'markdown')); } catch (Throwable $e) {} - } + // Resolved through the shared component: `readme` inline (a paid module's repo is private, so + // its marketplace serves the copy) or a `tiger_md` URL — the SAME resolution and the SAME safe + // render a marketplace's own listing page uses, so one listing can never look different here. + $descHtml = Tiger_Module_Longform::html($listing); $row = (new Tiger_Model_Module())->bySlug($slug); $discovered = Tiger_Module_Discovery::all(); @@ -753,17 +749,6 @@ protected function _inspectMarketplace(string $slug, string $source): void ]); } - /** Strip active content from untrusted vendor markdown (the TIGER.md preview). */ - protected function _scrub($html) - { - $html = (string) $html; - $html = preg_replace('#<(script|style|iframe|object|embed)\b[^>]*>.*?\1>#is', '', $html); - $html = preg_replace('#<(script|style|iframe|object|embed|link|meta|base)\b[^>]*>#is', '', $html); - $html = preg_replace('#\son\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $html); - $html = preg_replace('#(href|src)\s*=\s*(["\']?)\s*javascript:[^"\'>]*\2#i', '$1=$2#$2', $html); - return $html; - } - /** * Install (or update, with force) a module from a public GitHub URL. * diff --git a/tests/Unit/Module/LongformTest.php b/tests/Unit/Module/LongformTest.php new file mode 100644 index 00000000..562fd2a5 --- /dev/null +++ b/tests/Unit/Module/LongformTest.php @@ -0,0 +1,166 @@ +assertSame('# hello', Tiger_Module_Longform::markdown(['readme' => '# hello'])); + } + + /** `body` predates the shared name; a listing authored against it must keep rendering. */ + #[Test] + public function bodyIsAcceptedAsAnAlias(): void + { + $this->assertSame('# hello', Tiger_Module_Longform::markdown(['body' => '# hello'])); + } + + #[Test] + public function readmeWinsOverBody(): void + { + $this->assertSame('# a', Tiger_Module_Longform::markdown(['readme' => '# a', 'body' => '# b'])); + } + + /** A private (paid) module carries its copy inline; the URL is only for public repos. */ + #[Test] + public function inlineCopyWinsOverAFetchedUrl(): void + { + Tiger_Module_Longform::setTransport(static fn ($url) => '# fetched'); + + $this->assertSame('# inline', Tiger_Module_Longform::markdown([ + 'readme' => '# inline', + 'tiger_md' => 'https://example.test/TIGER.md', + ])); + } + + #[Test] + public function fetchesTigerMdWhenThereIsNoInlineCopy(): void + { + $seen = null; + Tiger_Module_Longform::setTransport(function ($url) use (&$seen) { + $seen = $url; + return "# Hello\n\nA paragraph."; + }); + + $md = Tiger_Module_Longform::markdown(['tiger_md' => 'https://example.test/a/TIGER.md']); + + $this->assertSame('https://example.test/a/TIGER.md', $seen); + $this->assertStringContainsString('# Hello', $md); + } + + #[Test] + public function aListingWithNeitherYieldsNothing(): void + { + $this->assertSame('', Tiger_Module_Longform::markdown([])); + $this->assertSame('', Tiger_Module_Longform::html([])); + } + + /** A body served over plain http can be rewritten in transit — refuse it before fetching. */ + #[Test] + public function refusesANonHttpsUrl(): void + { + $called = false; + Tiger_Module_Longform::setTransport(function () use (&$called) { $called = true; return '# nope'; }); + + $this->assertSame('', Tiger_Module_Longform::markdown(['tiger_md' => 'http://example.test/TIGER.md'])); + $this->assertFalse($called, 'an http:// body must never even be fetched'); + } + + #[Test] + public function rendersMarkdownToHtml(): void + { + $html = Tiger_Module_Longform::render("# Title\n\nSome **bold** copy."); + + $this->assertStringContainsString('