From 73f3606b6ea318188fb5b3fd581be850857f99ef Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 20 Aug 2026 16:28:29 +0100 Subject: [PATCH] Clip long support emails and reroute README license links The support channel email overflowed the plugin details sidebar, pushing the envelope icon outside the card. It's now clipped with an ellipsis and the full address moved to a title tooltip. READMEs commonly link to a license file relatively (LICENSE.md, ./LICENSE, LICENSE-MIT.txt) or by absolute GitHub URL, both of which 404 on our domain. Those links are now rewritten to our hosted license page, or to the file in the plugin's repository when we don't host one. Co-Authored-By: Claude Opus 5 (1M context) --- app/Models/Plugin.php | 30 +++- app/Support/PluginReadme.php | 143 ++++++++++++++++ resources/views/plugin-show.blade.php | 13 +- tests/Feature/PluginReadmeLicenseLinkTest.php | 155 ++++++++++++++++++ .../Feature/PluginShowSupportChannelTest.php | 15 ++ 5 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 app/Support/PluginReadme.php create mode 100644 tests/Feature/PluginReadmeLicenseLinkTest.php diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index f0026d32..85a1e0d0 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -13,8 +13,10 @@ use App\Services\OgImageService; use App\Services\PluginSyncService; use App\Services\SatisService; +use App\Support\PluginReadme; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; @@ -514,6 +516,22 @@ public function getLicense(): ?string } public function getLicenseUrl(): ?string + { + return $this->getRepositoryFileUrl('LICENSE'); + } + + /** + * Whether we host the license agreement for this plugin ourselves. + */ + public function hasLicensePage(): bool + { + return $this->isPaid() && filled($this->license_html); + } + + /** + * Build a URL to a file at the root of the plugin's repository. + */ + public function getRepositoryFileUrl(string $path): ?string { $repoInfo = $this->getRepositoryOwnerAndName(); @@ -521,7 +539,17 @@ public function getLicenseUrl(): ?string return null; } - return "https://github.com/{$repoInfo['owner']}/{$repoInfo['repo']}/blob/main/LICENSE"; + return "https://github.com/{$repoInfo['owner']}/{$repoInfo['repo']}/blob/main/".ltrim($path, '/'); + } + + /** + * The README, with links to the plugin's license file pointed at our license page. + */ + protected function renderedReadmeHtml(): Attribute + { + return Attribute::make(get: fn () => $this->readme_html + ? PluginReadme::rewriteLicenseLinks($this->readme_html, $this) + : $this->readme_html); } public function generateWebhookSecret(): string diff --git a/app/Support/PluginReadme.php b/app/Support/PluginReadme.php new file mode 100644 index 00000000..7b76a9a2 --- /dev/null +++ b/app/Support/PluginReadme.php @@ -0,0 +1,143 @@ +]*?\bhref\s*=\s*)(["\'])(.*?)\2/i', + function (array $matches) use ($plugin): string { + $url = static::licenseUrlFor(htmlspecialchars_decode($matches[3], ENT_QUOTES), $plugin); + + return $url === null + ? $matches[0] + : $matches[1].$matches[2].e($url).$matches[2]; + }, + $html + ); + + return $rewritten ?? $html; + } + + /** + * Resolve the URL a license link should point at, or null if it isn't a license link. + */ + protected static function licenseUrlFor(string $href, Plugin $plugin): ?string + { + $file = static::licenseFile($href, $plugin); + + if ($file === null) { + return null; + } + + if ($plugin->hasLicensePage()) { + return route('plugins.license', $plugin->routeParams()); + } + + return $plugin->getRepositoryFileUrl($file); + } + + /** + * Extract the license file a link refers to, or null if it points elsewhere. + */ + protected static function licenseFile(string $href, Plugin $plugin): ?string + { + $href = trim($href); + + if ($href === '' || str_starts_with($href, '#')) { + return null; + } + + $parts = parse_url($href); + + if ($parts === false) { + return null; + } + + $file = isset($parts['scheme']) || isset($parts['host']) + ? static::repositoryFile($parts, $plugin) + : static::rootRelativeFile($parts['path'] ?? ''); + + return $file !== null && static::looksLikeLicenseFile($file) ? $file : null; + } + + /** + * Resolve a relative link that sits alongside the README at the repository root. + */ + protected static function rootRelativeFile(string $path): ?string + { + $path = ltrim(preg_replace('#^(?:\./)+#', '', $path) ?? '', '/'); + + return $path === '' || str_contains($path, '/') ? null : $path; + } + + /** + * Resolve an absolute GitHub link back to a file at the plugin's repository root. + * + * @param array $parts + */ + protected static function repositoryFile(array $parts, Plugin $plugin): ?string + { + if (! in_array(strtolower($parts['scheme'] ?? 'https'), ['http', 'https'], true)) { + return null; + } + + $repo = $plugin->getRepositoryOwnerAndName(); + + if (! $repo) { + return null; + } + + // github.com/{owner}/{repo}/blob/{ref}/{file} or raw.githubusercontent.com/{owner}/{repo}/{ref}/{file} + $fileIndex = match (strtolower((string) ($parts['host'] ?? ''))) { + 'github.com', 'www.github.com' => 4, + 'raw.githubusercontent.com' => 3, + default => null, + }; + + $segments = array_values(array_filter(explode('/', (string) ($parts['path'] ?? '')), 'strlen')); + + if ($fileIndex === null || count($segments) !== $fileIndex + 1) { + return null; + } + + if (strcasecmp($segments[0], $repo['owner']) !== 0 || strcasecmp($segments[1], $repo['repo']) !== 0) { + return null; + } + + if ($fileIndex === 4 && ! in_array(strtolower($segments[2]), ['blob', 'raw'], true)) { + return null; + } + + return $segments[$fileIndex]; + } + + protected static function looksLikeLicenseFile(string $file): bool + { + $name = mb_strtolower(rawurldecode($file)); + $name = preg_replace('/\.(?:'.static::LICENSE_EXTENSIONS.')$/', '', $name) ?? $name; + + return (bool) preg_match(static::LICENSE_NAME, $name); + } +} diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index 2cbc402d..2bb6e7d0 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -246,7 +246,7 @@ class="prose prose-gallery min-w-0 max-w-none grow text-gray-600 prose-headings: aria-labelledby="plugin-title" > @if ($plugin->readme_html) - {!! $plugin->readme_html !!} + {!! $plugin->rendered_readme_html !!} @else

@@ -403,7 +403,7 @@ class="text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indig

License
@if ($plugin->getLicense()) - @if ($plugin->isPaid() && $plugin->license_html) + @if ($plugin->hasLicensePage()) support_channel }}" - class="inline-flex items-center gap-1 text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300" + title="{{ $plugin->support_channel }}" + class="inline-flex max-w-full items-center gap-1 text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300" > - {{ $plugin->support_channel }} - + {{ $plugin->support_channel }} + @else - {{ $plugin->support_channel }} + {{ $plugin->support_channel }} @endif
diff --git a/tests/Feature/PluginReadmeLicenseLinkTest.php b/tests/Feature/PluginReadmeLicenseLinkTest.php new file mode 100644 index 00000000..edf5ab14 --- /dev/null +++ b/tests/Feature/PluginReadmeLicenseLinkTest.php @@ -0,0 +1,155 @@ +approved()->paid()->create([ + 'name' => 'acme/paid-plugin', + 'repository_url' => 'https://github.com/acme/paid-plugin', + 'readme_html' => $readmeHtml, + 'license_html' => '

License agreement content

', + ]); + + PluginPrice::factory()->regular()->create([ + 'plugin_id' => $plugin->id, + 'amount' => 2999, + ]); + + return $plugin; + } + + /** + * @return array + */ + public static function licenseFileProvider(): array + { + return [ + 'bare name' => ['LICENSE'], + 'markdown' => ['LICENSE.md'], + 'text' => ['LICENSE.txt'], + 'lowercase' => ['license.md'], + 'british spelling' => ['LICENCE.md'], + 'unlicense' => ['UNLICENSE'], + 'copying' => ['COPYING'], + 'suffixed' => ['LICENSE-MIT.md'], + 'dot slash prefixed' => ['./LICENSE.md'], + 'root prefixed' => ['/LICENSE.md'], + 'github blob url' => ['https://github.com/acme/paid-plugin/blob/main/LICENSE.md'], + 'github raw url' => ['https://raw.githubusercontent.com/acme/paid-plugin/main/LICENSE'], + ]; + } + + #[DataProvider('licenseFileProvider')] + public function test_license_links_are_rerouted_to_the_license_page(string $href): void + { + $plugin = $this->createPaidPlugin('

See the license.

'); + + $this->assertStringContainsString( + 'license', + $plugin->rendered_readme_html + ); + } + + public function test_license_links_are_rerouted_when_the_readme_is_rendered(): void + { + $plugin = $this->createPaidPlugin('

See the license.

'); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertSee('license', false) + ->assertDontSee('', false); + } + + public function test_multiple_license_links_are_all_rerouted(): void + { + $plugin = $this->createPaidPlugin( + '

MIT and terms

' + ); + + $licenseUrl = route('plugins.license', $plugin->routeParams()); + + $this->assertSame( + '

MIT and terms

', + $plugin->rendered_readme_html + ); + } + + /** + * @return array + */ + public static function nonLicenseLinkProvider(): array + { + return [ + 'other document' => ['CONTRIBUTING.md'], + 'nested file' => ['docs/LICENSE.md'], + 'anchor' => ['#license'], + 'unrelated script' => ['https://example.com/license-checker.js'], + 'another repository' => ['https://github.com/other/repo/blob/main/LICENSE.md'], + 'repository subdirectory' => ['https://github.com/acme/paid-plugin/blob/main/docs/LICENSE.md'], + 'repository tree' => ['https://github.com/acme/paid-plugin/tree/main/LICENSE.md'], + 'mail link' => ['mailto:license@example.com'], + ]; + } + + #[DataProvider('nonLicenseLinkProvider')] + public function test_other_links_are_left_alone(string $href): void + { + $plugin = $this->createPaidPlugin('

link

'); + + $this->assertSame( + '

link

', + $plugin->rendered_readme_html + ); + } + + public function test_license_links_point_at_the_repository_when_there_is_no_license_page(): void + { + $plugin = Plugin::factory()->approved()->free()->create([ + 'name' => 'acme/free-plugin', + 'repository_url' => 'https://github.com/acme/free-plugin', + 'readme_html' => '

license

', + ]); + + $this->assertSame( + '

license

', + $plugin->rendered_readme_html + ); + } + + public function test_license_links_are_untouched_without_a_repository_or_license_page(): void + { + $plugin = Plugin::factory()->approved()->free()->create([ + 'repository_url' => null, + 'readme_html' => '

license

', + ]); + + $this->assertSame('

license

', $plugin->rendered_readme_html); + } + + public function test_readme_without_content_is_left_as_is(): void + { + $plugin = Plugin::factory()->approved()->free()->create(['readme_html' => null]); + + $this->assertNull($plugin->rendered_readme_html); + } +} diff --git a/tests/Feature/PluginShowSupportChannelTest.php b/tests/Feature/PluginShowSupportChannelTest.php index 64cd44c8..177b8487 100644 --- a/tests/Feature/PluginShowSupportChannelTest.php +++ b/tests/Feature/PluginShowSupportChannelTest.php @@ -54,4 +54,19 @@ public function test_email_support_channel_is_not_truncated(): void $response->assertStatus(200); $response->assertSee('support@example.com', false); } + + public function test_long_support_email_is_clipped_to_its_container(): void + { + $email = 'nativecodeforge.contented345@passinbox.com'; + + $plugin = Plugin::factory()->approved()->create([ + 'support_channel' => $email, + ]); + + $response = $this->get(route('plugins.show', $plugin->routeParams())); + + $response->assertStatus(200); + $response->assertSee('title="'.$email.'"', false); + $response->assertSee(''.$email.'', false); + } }