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
30 changes: 29 additions & 1 deletion app/Models/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -514,14 +516,40 @@ 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();

if (! $repoInfo) {
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
Expand Down
143 changes: 143 additions & 0 deletions app/Support/PluginReadme.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

namespace App\Support;

use App\Models\Plugin;

class PluginReadme
{
/**
* File extensions a license file is commonly given.
*/
protected const LICENSE_EXTENSIONS = 'md|markdown|mdown|txt|rst|html?';

/**
* Matches LICENSE, LICENCE, UNLICENSE, COPYING and suffixed variants such as LICENSE-MIT.
*/
protected const LICENSE_NAME = '/^(?:(?:un)?licen[sc]e|copying)(?:[-_][a-z0-9.]+)?$/';

/**
* Point links to a plugin's license file at the license page we host for it.
*/
public static function rewriteLicenseLinks(string $html, Plugin $plugin): string
{
if ($html === '') {
return $html;
}

$rewritten = preg_replace_callback(
'/(<a\b[^>]*?\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<string, mixed> $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);
}
}
13 changes: 7 additions & 6 deletions resources/views/plugin-show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div class="rounded-xl border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-slate-800/50">
<p class="text-gray-500 dark:text-gray-400">
Expand Down Expand Up @@ -403,7 +403,7 @@ class="text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indig
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">License</dt>
<dd class="mt-1">
@if ($plugin->getLicense())
@if ($plugin->isPaid() && $plugin->license_html)
@if ($plugin->hasLicensePage())
<a
href="{{ route('plugins.license', $plugin->routeParams()) }}"
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"
Expand Down Expand Up @@ -473,13 +473,14 @@ class="inline-flex items-center gap-1 text-sm font-medium text-indigo-600 hover:
@elseif (filter_var($plugin->support_channel, FILTER_VALIDATE_EMAIL))
<a
href="mailto:{{ $plugin->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 }}
<x-heroicon-o-envelope class="size-3" />
<span class="truncate">{{ $plugin->support_channel }}</span>
<x-heroicon-o-envelope class="size-3 shrink-0" />
</a>
@else
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ $plugin->support_channel }}</span>
<span class="block truncate text-sm font-medium text-gray-900 dark:text-white" title="{{ $plugin->support_channel }}">{{ $plugin->support_channel }}</span>
@endif
</dd>
</div>
Expand Down
155 changes: 155 additions & 0 deletions tests/Feature/PluginReadmeLicenseLinkTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php

namespace Tests\Feature;

use App\Features\ShowPlugins;
use App\Models\Plugin;
use App\Models\PluginPrice;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Pennant\Feature;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;

class PluginReadmeLicenseLinkTest extends TestCase
{
use RefreshDatabase;

protected function setUp(): void
{
parent::setUp();

Feature::define(ShowPlugins::class, true);
}

private function createPaidPlugin(string $readmeHtml): Plugin
{
$plugin = Plugin::factory()->approved()->paid()->create([
'name' => 'acme/paid-plugin',
'repository_url' => 'https://github.com/acme/paid-plugin',
'readme_html' => $readmeHtml,
'license_html' => '<p>License agreement content</p>',
]);

PluginPrice::factory()->regular()->create([
'plugin_id' => $plugin->id,
'amount' => 2999,
]);

return $plugin;
}

/**
* @return array<string, array{0: string}>
*/
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('<p>See the <a href="'.$href.'">license</a>.</p>');

$this->assertStringContainsString(
'<a href="'.route('plugins.license', $plugin->routeParams()).'">license</a>',
$plugin->rendered_readme_html
);
}

public function test_license_links_are_rerouted_when_the_readme_is_rendered(): void
{
$plugin = $this->createPaidPlugin('<p>See the <a href="LICENSE.md">license</a>.</p>');

$this->get(route('plugins.show', $plugin->routeParams()))
->assertStatus(200)
->assertSee('<a href="'.route('plugins.license', $plugin->routeParams()).'">license</a>', false)
->assertDontSee('<a href="LICENSE.md">', false);
}

public function test_multiple_license_links_are_all_rerouted(): void
{
$plugin = $this->createPaidPlugin(
'<p><a href="LICENSE">MIT</a> and <a class="x" href=\'./LICENCE.txt\'>terms</a></p>'
);

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

$this->assertSame(
'<p><a href="'.$licenseUrl.'">MIT</a> and <a class="x" href=\''.$licenseUrl.'\'>terms</a></p>',
$plugin->rendered_readme_html
);
}

/**
* @return array<string, array{0: string}>
*/
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('<p><a href="'.$href.'">link</a></p>');

$this->assertSame(
'<p><a href="'.$href.'">link</a></p>',
$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' => '<p><a href="LICENSE.md">license</a></p>',
]);

$this->assertSame(
'<p><a href="https://github.com/acme/free-plugin/blob/main/LICENSE.md">license</a></p>',
$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' => '<p><a href="LICENSE.md">license</a></p>',
]);

$this->assertSame('<p><a href="LICENSE.md">license</a></p>', $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);
}
}
Loading