Skip to content

Commit 4a3dc86

Browse files
simonhampclaude
andauthored
Clip long support emails and reroute README license links (#477)
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) <noreply@anthropic.com>
1 parent e96c75c commit 4a3dc86

5 files changed

Lines changed: 349 additions & 7 deletions

File tree

app/Models/Plugin.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
use App\Services\OgImageService;
1414
use App\Services\PluginSyncService;
1515
use App\Services\SatisService;
16+
use App\Support\PluginReadme;
1617
use Illuminate\Database\Eloquent\Attributes\Scope;
1718
use Illuminate\Database\Eloquent\Builder;
19+
use Illuminate\Database\Eloquent\Casts\Attribute;
1820
use Illuminate\Database\Eloquent\Factories\HasFactory;
1921
use Illuminate\Database\Eloquent\Model;
2022
use Illuminate\Database\Eloquent\ModelNotFoundException;
@@ -514,14 +516,40 @@ public function getLicense(): ?string
514516
}
515517

516518
public function getLicenseUrl(): ?string
519+
{
520+
return $this->getRepositoryFileUrl('LICENSE');
521+
}
522+
523+
/**
524+
* Whether we host the license agreement for this plugin ourselves.
525+
*/
526+
public function hasLicensePage(): bool
527+
{
528+
return $this->isPaid() && filled($this->license_html);
529+
}
530+
531+
/**
532+
* Build a URL to a file at the root of the plugin's repository.
533+
*/
534+
public function getRepositoryFileUrl(string $path): ?string
517535
{
518536
$repoInfo = $this->getRepositoryOwnerAndName();
519537

520538
if (! $repoInfo) {
521539
return null;
522540
}
523541

524-
return "https://github.com/{$repoInfo['owner']}/{$repoInfo['repo']}/blob/main/LICENSE";
542+
return "https://github.com/{$repoInfo['owner']}/{$repoInfo['repo']}/blob/main/".ltrim($path, '/');
543+
}
544+
545+
/**
546+
* The README, with links to the plugin's license file pointed at our license page.
547+
*/
548+
protected function renderedReadmeHtml(): Attribute
549+
{
550+
return Attribute::make(get: fn () => $this->readme_html
551+
? PluginReadme::rewriteLicenseLinks($this->readme_html, $this)
552+
: $this->readme_html);
525553
}
526554

527555
public function generateWebhookSecret(): string

app/Support/PluginReadme.php

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
namespace App\Support;
4+
5+
use App\Models\Plugin;
6+
7+
class PluginReadme
8+
{
9+
/**
10+
* File extensions a license file is commonly given.
11+
*/
12+
protected const LICENSE_EXTENSIONS = 'md|markdown|mdown|txt|rst|html?';
13+
14+
/**
15+
* Matches LICENSE, LICENCE, UNLICENSE, COPYING and suffixed variants such as LICENSE-MIT.
16+
*/
17+
protected const LICENSE_NAME = '/^(?:(?:un)?licen[sc]e|copying)(?:[-_][a-z0-9.]+)?$/';
18+
19+
/**
20+
* Point links to a plugin's license file at the license page we host for it.
21+
*/
22+
public static function rewriteLicenseLinks(string $html, Plugin $plugin): string
23+
{
24+
if ($html === '') {
25+
return $html;
26+
}
27+
28+
$rewritten = preg_replace_callback(
29+
'/(<a\b[^>]*?\bhref\s*=\s*)(["\'])(.*?)\2/i',
30+
function (array $matches) use ($plugin): string {
31+
$url = static::licenseUrlFor(htmlspecialchars_decode($matches[3], ENT_QUOTES), $plugin);
32+
33+
return $url === null
34+
? $matches[0]
35+
: $matches[1].$matches[2].e($url).$matches[2];
36+
},
37+
$html
38+
);
39+
40+
return $rewritten ?? $html;
41+
}
42+
43+
/**
44+
* Resolve the URL a license link should point at, or null if it isn't a license link.
45+
*/
46+
protected static function licenseUrlFor(string $href, Plugin $plugin): ?string
47+
{
48+
$file = static::licenseFile($href, $plugin);
49+
50+
if ($file === null) {
51+
return null;
52+
}
53+
54+
if ($plugin->hasLicensePage()) {
55+
return route('plugins.license', $plugin->routeParams());
56+
}
57+
58+
return $plugin->getRepositoryFileUrl($file);
59+
}
60+
61+
/**
62+
* Extract the license file a link refers to, or null if it points elsewhere.
63+
*/
64+
protected static function licenseFile(string $href, Plugin $plugin): ?string
65+
{
66+
$href = trim($href);
67+
68+
if ($href === '' || str_starts_with($href, '#')) {
69+
return null;
70+
}
71+
72+
$parts = parse_url($href);
73+
74+
if ($parts === false) {
75+
return null;
76+
}
77+
78+
$file = isset($parts['scheme']) || isset($parts['host'])
79+
? static::repositoryFile($parts, $plugin)
80+
: static::rootRelativeFile($parts['path'] ?? '');
81+
82+
return $file !== null && static::looksLikeLicenseFile($file) ? $file : null;
83+
}
84+
85+
/**
86+
* Resolve a relative link that sits alongside the README at the repository root.
87+
*/
88+
protected static function rootRelativeFile(string $path): ?string
89+
{
90+
$path = ltrim(preg_replace('#^(?:\./)+#', '', $path) ?? '', '/');
91+
92+
return $path === '' || str_contains($path, '/') ? null : $path;
93+
}
94+
95+
/**
96+
* Resolve an absolute GitHub link back to a file at the plugin's repository root.
97+
*
98+
* @param array<string, mixed> $parts
99+
*/
100+
protected static function repositoryFile(array $parts, Plugin $plugin): ?string
101+
{
102+
if (! in_array(strtolower($parts['scheme'] ?? 'https'), ['http', 'https'], true)) {
103+
return null;
104+
}
105+
106+
$repo = $plugin->getRepositoryOwnerAndName();
107+
108+
if (! $repo) {
109+
return null;
110+
}
111+
112+
// github.com/{owner}/{repo}/blob/{ref}/{file} or raw.githubusercontent.com/{owner}/{repo}/{ref}/{file}
113+
$fileIndex = match (strtolower((string) ($parts['host'] ?? ''))) {
114+
'github.com', 'www.github.com' => 4,
115+
'raw.githubusercontent.com' => 3,
116+
default => null,
117+
};
118+
119+
$segments = array_values(array_filter(explode('/', (string) ($parts['path'] ?? '')), 'strlen'));
120+
121+
if ($fileIndex === null || count($segments) !== $fileIndex + 1) {
122+
return null;
123+
}
124+
125+
if (strcasecmp($segments[0], $repo['owner']) !== 0 || strcasecmp($segments[1], $repo['repo']) !== 0) {
126+
return null;
127+
}
128+
129+
if ($fileIndex === 4 && ! in_array(strtolower($segments[2]), ['blob', 'raw'], true)) {
130+
return null;
131+
}
132+
133+
return $segments[$fileIndex];
134+
}
135+
136+
protected static function looksLikeLicenseFile(string $file): bool
137+
{
138+
$name = mb_strtolower(rawurldecode($file));
139+
$name = preg_replace('/\.(?:'.static::LICENSE_EXTENSIONS.')$/', '', $name) ?? $name;
140+
141+
return (bool) preg_match(static::LICENSE_NAME, $name);
142+
}
143+
}

resources/views/plugin-show.blade.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ class="prose prose-gallery min-w-0 max-w-none grow text-gray-600 prose-headings:
246246
aria-labelledby="plugin-title"
247247
>
248248
@if ($plugin->readme_html)
249-
{!! $plugin->readme_html !!}
249+
{!! $plugin->rendered_readme_html !!}
250250
@else
251251
<div class="rounded-xl border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-slate-800/50">
252252
<p class="text-gray-500 dark:text-gray-400">
@@ -403,7 +403,7 @@ class="text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indig
403403
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">License</dt>
404404
<dd class="mt-1">
405405
@if ($plugin->getLicense())
406-
@if ($plugin->isPaid() && $plugin->license_html)
406+
@if ($plugin->hasLicensePage())
407407
<a
408408
href="{{ route('plugins.license', $plugin->routeParams()) }}"
409409
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"
@@ -473,13 +473,14 @@ class="inline-flex items-center gap-1 text-sm font-medium text-indigo-600 hover:
473473
@elseif (filter_var($plugin->support_channel, FILTER_VALIDATE_EMAIL))
474474
<a
475475
href="mailto:{{ $plugin->support_channel }}"
476-
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"
476+
title="{{ $plugin->support_channel }}"
477+
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"
477478
>
478-
{{ $plugin->support_channel }}
479-
<x-heroicon-o-envelope class="size-3" />
479+
<span class="truncate">{{ $plugin->support_channel }}</span>
480+
<x-heroicon-o-envelope class="size-3 shrink-0" />
480481
</a>
481482
@else
482-
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ $plugin->support_channel }}</span>
483+
<span class="block truncate text-sm font-medium text-gray-900 dark:text-white" title="{{ $plugin->support_channel }}">{{ $plugin->support_channel }}</span>
483484
@endif
484485
</dd>
485486
</div>
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Features\ShowPlugins;
6+
use App\Models\Plugin;
7+
use App\Models\PluginPrice;
8+
use Illuminate\Foundation\Testing\RefreshDatabase;
9+
use Laravel\Pennant\Feature;
10+
use PHPUnit\Framework\Attributes\DataProvider;
11+
use Tests\TestCase;
12+
13+
class PluginReadmeLicenseLinkTest extends TestCase
14+
{
15+
use RefreshDatabase;
16+
17+
protected function setUp(): void
18+
{
19+
parent::setUp();
20+
21+
Feature::define(ShowPlugins::class, true);
22+
}
23+
24+
private function createPaidPlugin(string $readmeHtml): Plugin
25+
{
26+
$plugin = Plugin::factory()->approved()->paid()->create([
27+
'name' => 'acme/paid-plugin',
28+
'repository_url' => 'https://github.com/acme/paid-plugin',
29+
'readme_html' => $readmeHtml,
30+
'license_html' => '<p>License agreement content</p>',
31+
]);
32+
33+
PluginPrice::factory()->regular()->create([
34+
'plugin_id' => $plugin->id,
35+
'amount' => 2999,
36+
]);
37+
38+
return $plugin;
39+
}
40+
41+
/**
42+
* @return array<string, array{0: string}>
43+
*/
44+
public static function licenseFileProvider(): array
45+
{
46+
return [
47+
'bare name' => ['LICENSE'],
48+
'markdown' => ['LICENSE.md'],
49+
'text' => ['LICENSE.txt'],
50+
'lowercase' => ['license.md'],
51+
'british spelling' => ['LICENCE.md'],
52+
'unlicense' => ['UNLICENSE'],
53+
'copying' => ['COPYING'],
54+
'suffixed' => ['LICENSE-MIT.md'],
55+
'dot slash prefixed' => ['./LICENSE.md'],
56+
'root prefixed' => ['/LICENSE.md'],
57+
'github blob url' => ['https://github.com/acme/paid-plugin/blob/main/LICENSE.md'],
58+
'github raw url' => ['https://raw.githubusercontent.com/acme/paid-plugin/main/LICENSE'],
59+
];
60+
}
61+
62+
#[DataProvider('licenseFileProvider')]
63+
public function test_license_links_are_rerouted_to_the_license_page(string $href): void
64+
{
65+
$plugin = $this->createPaidPlugin('<p>See the <a href="'.$href.'">license</a>.</p>');
66+
67+
$this->assertStringContainsString(
68+
'<a href="'.route('plugins.license', $plugin->routeParams()).'">license</a>',
69+
$plugin->rendered_readme_html
70+
);
71+
}
72+
73+
public function test_license_links_are_rerouted_when_the_readme_is_rendered(): void
74+
{
75+
$plugin = $this->createPaidPlugin('<p>See the <a href="LICENSE.md">license</a>.</p>');
76+
77+
$this->get(route('plugins.show', $plugin->routeParams()))
78+
->assertStatus(200)
79+
->assertSee('<a href="'.route('plugins.license', $plugin->routeParams()).'">license</a>', false)
80+
->assertDontSee('<a href="LICENSE.md">', false);
81+
}
82+
83+
public function test_multiple_license_links_are_all_rerouted(): void
84+
{
85+
$plugin = $this->createPaidPlugin(
86+
'<p><a href="LICENSE">MIT</a> and <a class="x" href=\'./LICENCE.txt\'>terms</a></p>'
87+
);
88+
89+
$licenseUrl = route('plugins.license', $plugin->routeParams());
90+
91+
$this->assertSame(
92+
'<p><a href="'.$licenseUrl.'">MIT</a> and <a class="x" href=\''.$licenseUrl.'\'>terms</a></p>',
93+
$plugin->rendered_readme_html
94+
);
95+
}
96+
97+
/**
98+
* @return array<string, array{0: string}>
99+
*/
100+
public static function nonLicenseLinkProvider(): array
101+
{
102+
return [
103+
'other document' => ['CONTRIBUTING.md'],
104+
'nested file' => ['docs/LICENSE.md'],
105+
'anchor' => ['#license'],
106+
'unrelated script' => ['https://example.com/license-checker.js'],
107+
'another repository' => ['https://github.com/other/repo/blob/main/LICENSE.md'],
108+
'repository subdirectory' => ['https://github.com/acme/paid-plugin/blob/main/docs/LICENSE.md'],
109+
'repository tree' => ['https://github.com/acme/paid-plugin/tree/main/LICENSE.md'],
110+
'mail link' => ['mailto:license@example.com'],
111+
];
112+
}
113+
114+
#[DataProvider('nonLicenseLinkProvider')]
115+
public function test_other_links_are_left_alone(string $href): void
116+
{
117+
$plugin = $this->createPaidPlugin('<p><a href="'.$href.'">link</a></p>');
118+
119+
$this->assertSame(
120+
'<p><a href="'.$href.'">link</a></p>',
121+
$plugin->rendered_readme_html
122+
);
123+
}
124+
125+
public function test_license_links_point_at_the_repository_when_there_is_no_license_page(): void
126+
{
127+
$plugin = Plugin::factory()->approved()->free()->create([
128+
'name' => 'acme/free-plugin',
129+
'repository_url' => 'https://github.com/acme/free-plugin',
130+
'readme_html' => '<p><a href="LICENSE.md">license</a></p>',
131+
]);
132+
133+
$this->assertSame(
134+
'<p><a href="https://github.com/acme/free-plugin/blob/main/LICENSE.md">license</a></p>',
135+
$plugin->rendered_readme_html
136+
);
137+
}
138+
139+
public function test_license_links_are_untouched_without_a_repository_or_license_page(): void
140+
{
141+
$plugin = Plugin::factory()->approved()->free()->create([
142+
'repository_url' => null,
143+
'readme_html' => '<p><a href="LICENSE.md">license</a></p>',
144+
]);
145+
146+
$this->assertSame('<p><a href="LICENSE.md">license</a></p>', $plugin->rendered_readme_html);
147+
}
148+
149+
public function test_readme_without_content_is_left_as_is(): void
150+
{
151+
$plugin = Plugin::factory()->approved()->free()->create(['readme_html' => null]);
152+
153+
$this->assertNull($plugin->rendered_readme_html);
154+
}
155+
}

0 commit comments

Comments
 (0)