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); + } }