Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions MARKETPLACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
198 changes: 198 additions & 0 deletions library/Tiger/Module/Longform.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 K. Beau Beauchamp / WebTigers.
/**
* Tiger_Module_Longform — resolves a module listing's LONG-FORM copy and renders it safely.
*
* This is the "plugin page" body: the seller's full pitch, as opposed to the one-line `description`
* on a card. It reaches an install two ways, and this class is the single place that knows both:
*
* - **inline `readme`** — carried on the listing itself. A paid/PASS module's repo is PRIVATE, so
* there is no public file to fetch; its marketplace serves the review copy in the feed and the
* Module Manager renders that (see `System_Service_Modules::_inspectMarketplace`). `body` is
* accepted as an alias for listings authored before the name settled.
* - **by URL `tiger_md`** — the raw URL of a public repo's `TIGER.md` (the registry schema's
* field). Fetched, https-only, size-capped and cached on disk.
*
* **Why this exists as one class.** Two surfaces show the same copy — the Module Manager's "View
* more" and a marketplace's own listing page — and before this they rendered it two different ways
* with two different safety policies. One renderer means a listing can never look safe in one
* surface and unsafe in the other.
*
* **Security — the copy is UNTRUSTED.** It is a file written by whoever published the module. It is
* rendered through a **safe-mode Parsedown**, which escapes inline HTML at parse time and filters
* dangerous URL schemes. It deliberately does NOT go through `Tiger_Cms_Renderer::renderBody()`:
* that renders markdown through the shared `Parsedown::instance()` singleton with markup ALLOWED and
* then runs the `[shortcode]` pass — correct for a trusted CMS author, wrong for a third party,
* since it would both emit their markup and let them invoke this install's shortcodes. Escaping at
* parse time is also strictly stronger than stripping tags from rendered HTML afterwards, which is
* the classic thing to get subtly wrong.
*
* Fail-soft throughout: an unreachable URL, an oversized file or a parse failure yields `''`, never
* an exception into the screen that asked. `setTransport()` is a test seam so the flow is coverable
* with no network.
*
* @api
* @since 1.4.0
*/
class Tiger_Module_Longform
{
/** Cached fetches are re-checked hourly — vendor copy changes on their schedule, not ours. */
const CACHE_TTL = 3600;

/** Hard ceiling on a fetched body. Marketing copy is a few KB; past this it is not copy. */
const MAX_BYTES = 262144;

/** Seconds to wait on a vendor's host before giving up and showing the listing without a body. */
const TIMEOUT = 6;

/** The record fields carrying inline copy, in precedence order. */
const INLINE_FIELDS = ['readme', 'body'];

/** @var callable|null test seam: fn(string $url): ?string */
protected static $_transport = null;

/**
* Override the HTTP fetch (tests). Pass null to restore the real one.
*
* @param callable|null $transport fn(string $url): ?string — the raw body, or null on failure
* @return void
*/
public static function setTransport($transport = null)
{
self::$_transport = $transport;
}

/**
* A listing's long-form copy as safe HTML.
*
* @param array $listing the listing/record as the registry or a marketplace yields it
* @return string rendered HTML ('' when the listing carries no long-form copy)
*/
public static function html(array $listing)
{
$markdown = self::markdown($listing);
return $markdown === '' ? '' : self::render($markdown);
}

/**
* A listing's long-form copy as raw markdown — inline first, then by URL.
*
* @param array $listing the listing/record
* @return string the markdown ('' when there is none)
*/
public static function markdown(array $listing)
{
foreach (self::INLINE_FIELDS as $field) {
$inline = trim((string) (isset($listing[$field]) ? $listing[$field] : ''));
if ($inline !== '') { return $inline; }
}

$url = trim((string) (isset($listing['tiger_md']) ? $listing['tiger_md'] : ''));
return $url === '' ? '' : self::fetch($url);
}

/**
* Render untrusted markdown to safe HTML.
*
* Uses its OWN Parsedown instance, never the `Parsedown::instance()` singleton the CMS renderer
* shares — flipping safe mode on that would silently change how every CMS page renders.
*
* @param string $markdown the raw markdown
* @return string the rendered HTML ('' when Parsedown is unavailable or parsing fails)
*/
public static function render($markdown)
{
$markdown = (string) $markdown;
if ($markdown === '') { return ''; }

if (!class_exists('Parsedown', false)) {
$file = __DIR__ . '/../Cms/vendor/Parsedown.php';
if (!is_file($file)) { return ''; }
require_once $file;
}

try {
$parser = new Parsedown();
$parser->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';
}
}
25 changes: 5 additions & 20 deletions modules/system/services/Modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading