From d03e668c0ef397fb083d3ed2bde37f385b1467c8 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Mon, 14 Sep 2026 06:26:44 -0400 Subject: [PATCH] Theme assets reach a split docroot; symlink-off copy lands at the asset base (1.7.1) Found by the first one-click cPanel install (TIGER-39 on host3, symlink() disabled, subdomain docroot inside public_html): Grey Mist activated but /_greymist/* 404'd. Two causes: nothing mirrored a theme's asset base from /public into the docroot, and the symlink-off fallback published to _modules/ where the theme's HTML does not point. linkPublicAssets() now mirrors every public/_* entry into a split docroot (co-located unchanged); Tiger_Theme::activate() publishes to the request's DOCUMENT_ROOT as well; Tiger_Install::publishOne() is the one link-or-copy unit for both. Tests: mirror in link + copy mode, no self-loop when co-located, publishOne respects a user's real directory. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L8p9pLJ3DFstG3xZuh2QgZ --- CHANGELOG.md | 13 ++++++++ library/Tiger/Install.php | 40 ++++++++++++++++++++++++ library/Tiger/Theme.php | 17 ++++++++--- library/Tiger/Version.php | 2 +- tests/Unit/InstallAssetsTest.php | 52 ++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca50119..163b2f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +## [1.7.1] — 2026-09-14 + +### Fixed + +- **An activated theme's assets now reach a split docroot** (found by the first one-click cPanel + install: Grey Mist rendered unstyled because `/_greymist/*` 404'd). `Tiger_Install::linkPublicAssets()` + mirrors every `_*` entry Tiger publishes under `/public` — a theme's asset base, a module's own + publish — into the docroot when the docroot is not `/public`; `Tiger_Theme::activate()` also + publishes into the request's document root directly. Co-located installs are unchanged. +- **Theme assets on a `symlink()`-disabled host are copied to the theme's asset base** (`public/_greymist`), + not to `_modules/` where nothing references them. New `Tiger_Install::publishOne()` is the + one link-or-copy unit both paths use. + ## [1.7.0] — 2026-09-14 **One theme-activation authority, and the headless install path documented.** diff --git a/library/Tiger/Install.php b/library/Tiger/Install.php index 3270d5c..6862050 100644 --- a/library/Tiger/Install.php +++ b/library/Tiger/Install.php @@ -224,6 +224,18 @@ public static function linkPublicAssets($webroot, $root, $theme = 'puma') if (is_dir($modulesDir)) { $links['_modules'] = $modulesDir; } } + // Everything ELSE Tiger publishes under /public — a theme's asset base (`_greymist`, + // linked there by Tiger_Theme::activate()), or any module that publishes its own `_` — must + // reach the docroot too, or the site references assets the web server cannot see. On the + // split layout the docroot mirrors every `_*` entry of /public; co-located it already is. + if (realpath($webroot) !== realpath($root . '/public')) { + foreach (glob($root . '/public/_*') ?: [] as $entry) { + $name = basename($entry); + if (isset($links[$name]) || !is_dir($entry)) { continue; } + $links[$name] = is_link($entry) ? (string) readlink($entry) : $entry; + } + } + $made = []; foreach ($links as $name => $target) { if (!is_dir($target)) { @@ -266,6 +278,34 @@ public static function linkPublicAssets($webroot, $root, $theme = 'puma') return $made; } + /** + * Publish ONE asset directory at /: a symlink when the host allows it, else a + * marked copy (refreshed on republish). The unit linkPublicAssets() applies to each entry; a theme + * activation uses it for the theme's own asset base. + * + * @param string $webroot the dir the entry is created in + * @param string $name the entry name (e.g. `_greymist`) + * @param string $target the real asset directory + * @return bool true if linked, false if copied + * @throws RuntimeException when the entry is a user's real directory, or neither link nor copy worked + */ + public static function publishOne($webroot, $name, $target) + { + $link = rtrim((string) $webroot, '/') . '/' . $name; + if (!is_dir($target)) { throw new RuntimeException("publishOne: asset target not found: {$target}"); } + if (is_link($link)) { @unlink($link); } + elseif (is_dir($link)) { + if (!is_file($link . '/' . self::ASSET_COPY_MARKER)) { throw new RuntimeException("publishOne: refusing to replace a real directory: {$link}"); } + self::_rrmdir($link); + } elseif (file_exists($link)) { @unlink($link); } + if (static::_canSymlink() && @symlink($target, $link)) { return true; } + self::_rcopy($target, $link); + if (!is_dir($link)) { throw new RuntimeException("publishOne: could neither link nor copy {$target} -> {$link}"); } + @file_put_contents($link . '/' . self::ASSET_COPY_MARKER, + "Published by Tiger because symlink() is unavailable on this host.\n" . "Managed automatically — do not edit; it is replaced on update.\n"); + return false; + } + /** * Is this install serving COPIED assets rather than symlinks? True when either published dir is a * real directory carrying our marker — i.e. the host blocked `symlink()` at publish time. diff --git a/library/Tiger/Theme.php b/library/Tiger/Theme.php index a94dcfe..b0e50a6 100644 --- a/library/Tiger/Theme.php +++ b/library/Tiger/Theme.php @@ -279,16 +279,23 @@ protected static function publicDir() return $base . '/public'; } - /** Symlink a theme's assets/ to public/ (copy fallback where symlinks are blocked). */ + /** + * Publish a theme's assets/ at public/ — a symlink, or a marked COPY where symlink() + * is disabled (the copy used to land under _modules/, which is not where the theme's HTML + * points). On the split layout, where the web server's docroot is not /public, the same + * entry is published into the docroot too, or the theme's CSS 404s the moment it is activated. + */ protected static function _linkAssets($slug, $base, $area) { $root = ($area === 'app' && defined('APPLICATION_PATH')) ? APPLICATION_PATH : TIGER_CORE_PATH; $assets = $root . '/modules/' . $slug . '/assets'; if (!is_dir($assets)) { return; } - $link = self::publicDir() . '/' . ltrim((string) $base, '/'); - if (is_link($link)) { @unlink($link); } - if (!(function_exists('symlink') && @symlink($assets, $link)) && !is_dir($link)) { - Tiger_Module_Installer::publishAssets($slug); // best-effort; symlink is the norm on cPanel + $name = ltrim((string) $base, '/'); + if ($name === '' || strpos($name, '/') !== false || $name[0] !== '_') { return; } // an asset base is one `_x` segment + try { Tiger_Install::publishOne(self::publicDir(), $name, $assets); } catch (Throwable $e) { /* a user's dir of that name wins */ } + $doc = isset($_SERVER['DOCUMENT_ROOT']) ? rtrim((string) $_SERVER['DOCUMENT_ROOT'], '/') : ''; + if ($doc !== '' && is_dir($doc) && realpath($doc) !== realpath(self::publicDir())) { + try { Tiger_Install::publishOne($doc, $name, $assets); } catch (Throwable $e) { /* same */ } } } diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 3c0c7b2..48c1a81 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.7.0'; + const VERSION = '1.7.1'; } diff --git a/tests/Unit/InstallAssetsTest.php b/tests/Unit/InstallAssetsTest.php index 179065b..8898c7e 100644 --- a/tests/Unit/InstallAssetsTest.php +++ b/tests/Unit/InstallAssetsTest.php @@ -179,6 +179,58 @@ public function it_refuses_to_replace_a_real_directory_it_did_not_publish(): voi $this->assertSame('user content', file_get_contents($this->webroot . '/_theme/mine.txt')); } } + + /** + * A theme's asset base (`public/_greymist`, put there by Tiger_Theme::activate()) — and anything + * else Tiger publishes under /public — must reach a SPLIT docroot, or the activated theme's + * CSS 404s. Found on a real cPanel account: the theme rendered unstyled after a one-click install. + */ + #[Test] + public function it_mirrors_every_published_public_entry_into_a_split_docroot(): void + { + @mkdir($this->root . '/application/modules/theme-grey-mist/assets/css', 0775, true); + file_put_contents($this->root . '/application/modules/theme-grey-mist/assets/css/grey.css', '.grey{}'); + @mkdir($this->root . '/public', 0775, true); + symlink($this->root . '/application/modules/theme-grey-mist/assets', $this->root . '/public/_greymist'); + @mkdir($this->root . '/public/_other/x', 0775, true); // a module's own publish, a real dir + file_put_contents($this->root . '/public/_other/x/o.js', '1'); + file_put_contents($this->root . '/public/notes.txt', 'ignored'); // not an _entry + + $made = Tiger_Install::linkPublicAssets($this->webroot, $this->root, 'puma'); + $this->assertArrayHasKey('_greymist', $made); + $this->assertArrayHasKey('_other', $made); + $this->assertFileExists($this->webroot . '/_greymist/css/grey.css'); + $this->assertFileExists($this->webroot . '/_other/x/o.js'); + $this->assertFileDoesNotExist($this->webroot . '/notes.txt'); + + // Copy mode too — a real directory with the marker, refreshed on a second run. + $copied = NoSymlinkInstall::linkPublicAssets($this->webroot, $this->root, 'puma'); + $this->assertArrayHasKey('_greymist', $copied); + $this->assertFalse(is_link($this->webroot . '/_greymist')); + $this->assertFileExists($this->webroot . '/_greymist/' . Tiger_Install::ASSET_COPY_MARKER); + } + + #[Test] + public function it_does_not_mirror_when_the_docroot_is_public_itself(): void + { + @mkdir($this->root . '/public/_greymist', 0775, true); + $made = Tiger_Install::linkPublicAssets($this->root . '/public', $this->root, 'puma'); + $this->assertArrayNotHasKey('_greymist', $made, 'co-located: a link onto itself would loop'); + } + + #[Test] + public function publish_one_links_or_copies_a_single_entry_and_respects_a_users_directory(): void + { + $target = $this->root . '/vendor/webtigers/tiger-core/themes/puma/assets'; + $this->assertTrue(Tiger_Install::publishOne($this->webroot, '_x', $target)); + $this->assertTrue(is_link($this->webroot . '/_x')); + $this->assertFalse(NoSymlinkInstall::publishOne($this->webroot, '_x', $target), 'copied'); + $this->assertFileExists($this->webroot . '/_x/css/default.css'); + $this->assertFileExists($this->webroot . '/_x/' . Tiger_Install::ASSET_COPY_MARKER); + @mkdir($this->webroot . '/_mine', 0775, true); file_put_contents($this->webroot . '/_mine/keep', '1'); + $this->expectException(RuntimeException::class); + Tiger_Install::publishOne($this->webroot, '_mine', $target); + } } /** Forces the copy path — the only way to reach it on a dev machine that allows symlinks. */